# `tui`
The `tui` global is for simple terminal interactivity: asking the user
questions (**prompts**), producing colored, styled output (**`tui.styled`**),
and printing ready-made **message blocks** (`tui.success`, `tui.outlineError`,
…). Prompts render an interactive one-line UI on the terminal — a yes/no
question, a text field, a filterable option list, a calendar — and return the
answer as a plain Lua value. It is always available as the global `tui`.
```lua
local name = tui.promptText("Your name?")
local lang = tui.promptSelect("Favorite language?", "lua", { options = { "lua", "rust", "c" } })
if tui.confirm("Save results?", true) then
print(tui.styled("saved for " .. tui.escape(name) .. ">"))
end
```
## Prompts
All prompts share one shape:
```lua
tui.promptXxx(text, default, opts)
```
- `text` — the question shown to the user (required string).
- `default` — the prompt's default value; `nil` means none. (Exceptions:
`promptSelect` matches the default against the options *by value*;
`promptSecret` has no default parameter at all.)
- `opts` — an optional table of extended options, listed per prompt below.
Every prompt accepts `help` (a hint line shown below the prompt). Unknown
keys are an error, so typos fail loudly.
Shared behavior:
- **Esc returns `nil`.** Declining a prompt is a normal, checkable outcome —
this holds for `tui.confirm` too, since `false` ("answered no") is distinct
from `nil` ("didn't answer").
- **Ctrl-C raises an error** (`tui.: interrupted`), stopping the script
unless trapped with `pcall`/`utils.try`.
- **A terminal is required.** Prompting with stdin/stdout redirected raises
`tui.: not a terminal`.
- **Prompts are async.** Like `os.sleep` or `http`, a prompt suspends only the
calling coroutine (see [Concurrency](concurrency.md)). Prompts from
concurrent `task.join` branches are serialized — one owns the terminal at a
time — but `print`/`log` output from sibling coroutines can still interleave
visually with a live prompt.
- **Validation options re-prompt inline.** Constraint options like `min`,
`maxLen` or `minSelected` are enforced by the prompt UI itself (an inline
message, then the user tries again) — they do not raise Lua errors.
Malformed *arguments* (a default that is not among the options, `min > max`,
a bad date string) raise immediately, before the terminal is touched.
### `tui.confirm(text, default, opts)` → `boolean | nil`
Yes/no question, answered with `y`/`n`.
```lua
local ok = tui.confirm("Deploy to production?", false, { help = "y/N" })
if ok == nil then return end -- Esc: user backed out
```
- `default` — boolean returned when the user just presses Enter; with `nil`
the user must type an answer.
- opts: `help`, `placeholder`.
### `tui.promptText(text, default, opts)` → `string | nil`
One-line text input.
```lua
local host = tui.promptText("Host?", "localhost", {
placeholder = "hostname or IP",
suggestions = { "localhost", "db.internal", "cache.internal" },
})
```
- `default` — pre-filled **editable** text (the user edits it in place).
- opts:
- `placeholder` — dim hint shown while the input is empty.
- `default` — string returned when the input is submitted empty (shown as a
hint; distinct from the pre-filled positional default, and both can be
used together).
- `minLen`, `maxLen` — length bounds (characters), enforced inline.
- `suggestions` — array of strings offered as autocompletion
(case-insensitive substring match; Tab completes the highlighted one).
- `pageSize` — how many suggestions are visible at once.
### `tui.promptLongText(text, default, opts)` → `string | nil`
Multi-line input via an external editor: opens `$VISUAL`/`$EDITOR` (falling
back to a platform default) on a temp file and returns its contents on save.
```lua
local body = tui.promptLongText("Release notes", "## Changes\n\n", { extension = ".md" })
```
- `default` — text the editor buffer starts with.
- opts:
- `extension` — temp-file extension (drives editor syntax highlighting);
a missing leading dot is added, default `.txt`.
- `editor` — editor command to use instead of `$VISUAL`/`$EDITOR`.
### `tui.promptSelect(text, default, opts)` → `string | string[] | nil`
Pick from a list. The third argument is **required** — it carries the options.
The list is filterable by typing (fuzzy match); arrows or `vimMode` keys move
the cursor.
```lua
local color = tui.promptSelect("Color?", "green", { options = { "red", "green", "blue" } })
local picks = tui.promptSelect("Toppings?", { "cheese" }, {
options = { "cheese", "ham", "mushrooms", "olives" },
multiple = true,
minSelected = 1,
})
```
- `default` — matched against the options **by value**; a value that is not
among the options raises an error (a stale default is a script bug). Single
mode: a string (the cursor starts there). Multiple mode: a string or an
array of strings (pre-checked).
- opts:
- `options` — non-empty array of strings (**required**).
- `multiple` — `true` switches to checkbox-style multi-select (Space
toggles, Enter submits) and the result becomes an array of the chosen
strings (possibly empty).
- `pageSize` — visible rows (default 7).
- `vimMode` — `hjkl` navigation.
- `filter` — set `false` to disable the type-to-filter input.
- multiple-only: `minSelected` / `maxSelected` (selection-count bounds,
enforced inline), `allSelected` (start with everything checked).
### `tui.promptSecret(text, opts)` → `string | nil`
Secret input. Deliberately has **no default parameter**.
```lua
local secret = tui.promptSecret("API token", { confirm = true, minLen = 8 })
```
- opts:
- `confirm` — ask twice and require both entries to match (default `false`).
- `display` — `"masked"` (default, `*` per character), `"hidden"` (no echo
at all), or `"full"` (plain text).
- `toggle` — allow Ctrl-R to toggle revealing the input.
- `minLen` — minimum length, enforced inline.
### `tui.promptNumber(text, default, opts)` → `number | nil`
### `tui.promptInt(text, default, opts)` → `integer | nil`
Numeric input; the entry is parsed and re-prompted until it is a valid number
(`promptNumber`) or whole number (`promptInt`).
```lua
local ratio = tui.promptNumber("Ratio?", 0.5, { min = 0, max = 1 })
local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 })
```
- `default` — returned when the input is submitted empty. `promptInt`
rejects a fractional default (`1.5`) at call time.
- opts: `placeholder`, `min`, `max` (bounds enforced inline; `min > max` is a
call-time error).
### `tui.promptDate(text, default, opts)` → `string | nil`
Date picker: an interactive calendar navigated with the arrow keys. Dates
cross the API as `"YYYY-MM-DD"` strings in both directions.
```lua
local day = tui.promptDate("Start date?", "2026-07-06", {
min = "2026-01-01",
max = "2026-12-31",
weekStart = "monday",
})
```
- `default` — the initially selected date, `"YYYY-MM-DD"`.
- opts:
- `min`, `max` — selectable range, `"YYYY-MM-DD"`.
- `weekStart` — first day of the calendar week: a weekday name like
`"monday"` or `"sun"` (default Sunday).
## `tui.styled(markup)` → `string`
Renders text with HTML-like style tags into a string with ANSI escape
sequences, ready to `print`. The tag syntax follows
[Symfony console formatting](https://symfony.com/doc/current/console/coloring.html).
```lua
print(tui.styled(" FAIL expected 42, see " ..
"the docs> for details>"))
```
### Built-in styles
| Tag | Rendering |
|-----|-----------|
| `` | bold green text |
| `` | white on red |
| `` | **bold** |
| `` | *italic* |
| `` | underscore |
| `` | ~~strikethrough~~ |
(Symfony's `` and `` are deliberately not built in — add
them with `tui.addPreset` if you want them.)
### Inline styles
``, `` and `` can be combined in one tag,
separated by `;`:
```lua
tui.styled("ok> careful> brick red>")
```
- Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
`white`, `gray`, their `bright-*` variants, `default` (the terminal's own
color — as an *inner* tag it resets an inherited color), and hex
`#rrggbb` / `#rgb` (truecolor).
- Options (comma-separated): `bold`, `italic`, `underscore`, `blink`,
`reverse`, `conceal`, `strikethrough`.
- The syntax is strict: no spaces around `=` or `;`.
### Shorthand
Bare tokens work without the `fg=`/`options=` keys: a color names the
foreground, `on-` the background, and option names apply directly.
Shorthand mixes freely with `key=value` parts and built-in names:
```lua
tui.styled("alarm> <#f0a;u>pink underline> bold green>")
```
### Custom presets — `tui.addPreset(name, style)`
Register your own tag name for any style spec. The spec uses the same syntax
as tags (attributes, shorthand, or another preset/built-in name); the name
must be identifier-like (start with a letter; letters, digits, `-`, `_`).
```lua
tui.addPreset("keyword", "fg=white;bg=magenta")
tui.addPreset("em", "i;bold")
print(tui.styled("the local keyword is important"))
tui.note("see the docs") -- NOT styled: block text is plain
tui.block("styled block", { style = "keyword" }) -- but block styles resolve presets
```
Re-registering a name overwrites it, and a preset may shadow a built-in name
or shorthand token (e.g. `addPreset("info", "fg=blue")` restyles ``).
### Closing tags and nesting
`>` closes the innermost open style; named forms (``,
``) work too and do the same thing. Styles nest — an inner tag
overrides only what it sets and inherits the rest:
```lua
tui.styled("fail in step 2> of job")
-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red
```
### Hyperlinks
`` emits an [OSC-8 hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda);
supporting terminals show clickable text, others show just the text:
```lua
tui.styled("see the manual>")
```
### Action tags: cursor movement and clearing
Besides styles, markup can move the cursor and clear parts of the display —
handy for single-line redraws without string-formatting escape codes by hand:
```lua
for i = 1, 100 do
tui.raw(tui.styled(("progress %d%%"):format(i)))
-- ... work ...
end
tui.raw("\n")
```
| Tag | Effect |
|-----|--------|
| `` `` `` `` | move the cursor by one cell |
| `` … | move by *n* cells |
| `` | absolute move, 1-based; each axis works alone too |
| `` | column 1 of the current line |
| `` | top-left corner |
| `` | clear the whole line, cursor to column 1 |
| `` | clear to the start of the line, cursor to column 1 |
| `` | clear to the end of the line, cursor stays put |
| `` | clear the whole screen, cursor home |
| `` | clear above the cursor, cursor home |
| `` | clear below the cursor, cursor stays put |
Unlike style tags, action tags emit their escape sequence *in place* — they
don't enclose text and have no closing form (`
` is literal text). Ops
combine in one tag, applied left to right: `` moves up one
line and blanks it. Whole and to-start clears also return the cursor (column
1 or home, matching `tui.clearLine`/`tui.clearScreen`), so the redraw starts
from a known spot; the `end`/`down` variants leave it in place.
Like all escape output, action tags are dropped when colors are off (pipes,
`NO_COLOR`) — piped output never contains control bytes. A custom preset may
shadow an action name (`addPreset("up", …)` turns `` into a style tag).
Stateful operations — the alternate screen, cursor visibility, window title —
are deliberately **not** tags; they need cleanup tracking and live in the
[terminal control functions](#terminal-control) below.
### Escaping and invalid tags
A backslash escapes a literal bracket: `"\\"` prints ``; `"\\\\"`
prints a single backslash. Anything that does not parse as a tag —
``, `` (tags are case-sensitive), a lone `<`, a bad color — is
passed through as literal text, never an error. For dynamic text, don't
escape by hand — use [`tui.escape`](#tuiescapetext--string).
### Color detection
When output is piped or `NO_COLOR` is set, `tui.styled` returns the plain
text: tags are still consumed, but no escape codes (colors or hyperlinks) are
emitted. Detection follows the usual conventions (`NO_COLOR`, `CLICOLOR`,
`CLICOLOR_FORCE`, tty check on stdout).
## `tui.escape(text)` → `string`
Backslash-escapes the markup metacharacters (`<`, `>`, `\`) so text from
outside the program — user input, file contents, API responses — can be
embedded in a `tui.styled` format string and come out exactly as it went in,
styled only by the enclosing tags:
```lua
local city = tui.promptText("City?")
print(tui.styled(("looking up %s…"):format(tui.escape(city))))
```
Without escaping, a user who types `` gets it rendered as a red block,
and stray brackets can eat the format string's own tags. Only `tui.styled`
markup needs this; block messages (`tui.warning` & co.) are plain text and
never interpret tags.
## Message blocks
Ready-made status messages in the style of Symfony's console: a labeled,
word-wrapped block, padded to the terminal width (capped at 120 columns) and
painted with a background color. They **print directly to stdout**, surrounded
by blank lines. When output is piped (or `NO_COLOR` is set) they degrade to
plain aligned text.
```lua
tui.success("Deployment finished")
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" })
tui.error("Build failed")
```
| Function | Label | Style |
|---|---|---|
| `tui.success(message, opts)` | `[OK]` | black on green |
| `tui.error(message, opts)` | `[ERROR]` | white on red |
| `tui.warning(message, opts)` | `[WARNING]` | black on yellow |
| `tui.caution(message, opts)` | `[CAUTION]` | white on red, `!` prefix |
| `tui.info(message, opts)` | `[INFO]` | green text |
| `tui.note(message, opts)` | `[NOTE]` | yellow text, `!` prefix |
| `tui.comment(message, opts)` | — | gray text |
| `tui.block(message, opts)` | — | generic: everything from opts |
- `message` — a string or an array of strings (rendered into one block,
separated by a blank line). Treated as **plain text**: markup tags are not
interpreted here (compose with `tui.styled` on your own lines instead), and
lines wrap by word to fit the width.
- `opts` (each overrides the preset's default):
- `label` — the `[LABEL]` text; continuation lines align under it.
- `style` — same syntax as a `styled` tag body: a preset or built-in name
(`"error"`, anything from `tui.addPreset`), a spec (`"fg=black;bg=cyan"`),
or shorthand (`"black;on-cyan"`). An invalid style is an error.
- `prefix` — string prepended to every line (default `" "`).
- `padding` — blank styled line above and below the text (only applies when
colors are on; without a background it would just be empty lines).
### Outline blocks
The same messages as a bordered box — colored **borders** instead of colored
backgrounds, which stays readable on any terminal color scheme:
```
┌─ Success ──────────────────────────┐
│ │
│ All tests passed │
│ │
└────────────────────────────────────┘
```
```lua
tui.outlineSuccess("All tests passed")
tui.outlineError("Build failed", { title = "Compile error" })
tui.outlineBlock("Anything", { title = "Stats", style = "fg=magenta" })
```
| Function | Fallback title | Border style |
|---|---|---|
| `tui.outlineSuccess` | Success | green |
| `tui.outlineError` | Error | red |
| `tui.outlineWarning` | Warning | yellow |
| `tui.outlineNote` | Note | blue |
| `tui.outlineInfo` | Info | green |
| `tui.outlineCaution` | Caution | red |
| `tui.outlineBlock` | — | — (generic) |
- `opts`: `title` (shown in the top border; overrides the preset's fallback),
`style` (border color, tag-body syntax), `padding` (blank line above/below
the content **inside** the box, default `true`).
## Low-level output
### `tui.raw(...)`
Writes its arguments to stdout with **no newline and no separator** (each is
converted with `tostring`), then flushes. This is the building block for
progress-style output that redraws one line:
```lua
for i = 1, 100 do
tui.raw("\rprocessing ", i, "/100")
-- ... work ...
end
tui.raw("\n")
```
### `tui.screenInfo()` → `table`
Facts about the terminal, for scripts that render their own UI (progress
bars, right-aligned text, …):
| Field | Meaning |
|---|---|
| `width`, `height` | terminal size in cells; `nil` when there is no terminal to measure |
| `tty` | whether stdout is a terminal (`false` when piped) |
| `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) |
## Terminal control
Direct terminal manipulation for scripts that render their own UI: cursor
movement, clearing, the alternate screen, scroll regions, the window title
and the clipboard. These are the standard xterm-style escape sequences,
wrapped as functions.
```lua
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
tui.moveTo(2, 4)
tui.raw(tui.styled("fullscreen!"))
os.sleep(2)
-- no explicit restore needed: the runtime cleans up on exit
```
Shared behavior:
- **No-ops without a terminal.** When stdout is not a tty, every function
validates its arguments and then does nothing — piped output never
contains control bytes. This is a plain tty check (unlike color
detection): `NO_COLOR` turns off colors, not cursor control.
- **Bad arguments always raise**, tty or not — typos fail loudly in pipes
and CI too.
- **Automatic cleanup.** The runtime tracks the alternate screen, a hidden
cursor, a changed cursor style, a scroll region and a pushed window title,
and restores all of them when the script ends — on normal exit, on error
(the message prints on the normal screen), and on a crash. Scripts should
still restore what they change for mid-script tidiness, but cannot wreck
the terminal by forgetting.
### Cursor
- **`tui.moveTo(row, col)`** — absolute move, 1-based (row 1, column 1 is
the top-left corner). Passing `nil` for one axis keeps it:
`tui.moveTo(5)` jumps to row 5, `tui.moveTo(nil, 1)` to column 1.
- **`tui.move(dx, dy)`** — relative move: positive `dx` right, positive `dy`
down; negative values go the other way, `nil` counts as 0. The terminal
clamps at the screen edges.
- **`tui.saveCursor()` / `tui.restoreCursor()`** — save and restore the
cursor position (one slot; a save overwrites the previous one).
- **`tui.cursorPos()` → `row, col`** — ask the terminal where the cursor is
(1-based). Returns `nil` when there is no terminal or it doesn't answer.
Like the prompts this is async and serialized against them, so a
concurrent prompt can't eat the terminal's reply.
- **`tui.showCursor(visible)`** — hide (`false`) or show (`true` or no
argument) the cursor. A hidden cursor is re-shown on exit.
- **`tui.cursorStyle(style, opts)`** — set the cursor shape: `"block"`,
`"underline"` or `"bar"`, with `{ blink = true }` for the blinking
variant. No arguments reset to the terminal default (also done on exit).
### Clearing
- **`tui.clearLine(mode)`** / **`tui.clearScreen(mode)`** — erase the
current line / the screen. `mode` selects what: `0` or `nil` = whole,
`-1` = up to the cursor, `1` = from the cursor to the end. Whole and
to-start clears also return the cursor to a known spot (column 1 / home);
to-end clears leave it in place.
### Alternate screen
- **`tui.altScreen(on)`** — switch to (`true`) or back from (`false`) the
alternate screen buffer, the full-screen-app mode where the normal
scrollback stays untouched. Switching in an already-active direction is
ignored, and the runtime always switches back on exit.
### Scrolling
- **`tui.scrollRegion(top, bottom)`** — restrict scrolling to the given
rows (1-based, inclusive; `top < bottom`) — the basis for fixed
headers/footers. Setting a region moves the cursor home. With no arguments
the region resets to the full screen (also done on exit).
- **`tui.scroll(n)`** — scroll the scroll region: positive `n` scrolls the
content up by `n` lines, negative down, `0` does nothing. The cursor does
not move.
### Terminal integration
- **`tui.setTitle(text)`** — set the terminal window title. The first call
saves the current title on the terminal's title stack, and the runtime
restores it on exit. Control characters in the title are an error.
- **`tui.setClipboard(text)`** — copy `text` into the system clipboard via
the terminal (OSC 52). Write-only: reading the clipboard is disabled by
most terminals for good reasons. Works over SSH in supporting terminals;
some terminals require enabling clipboard access in their settings.
- **`tui.reset()`** — soft-reset the terminal (DECSTR): un-sticks stuck
attributes, a garbled charset, a forgotten scroll region — without
clearing the screen. The heavy-handed fixer for "my output looks insane".
## Notes
- **Always loaded.** No `require`; `tui` is a global in every script
(`require "tui"` also works and returns the same table).
- **Prompts need a terminal.** In pipes, cron, CI etc. every prompt raises
`not a terminal`; `tui.styled` and the blocks still work and degrade to
plain text, and the terminal-control functions become no-ops.
- **Progress bars** can be built from `tui.raw` + `tui.screenInfo` + the
`` action tag; a ready-made widget may come later.
- **Key events** (raw keyboard input, mouse tracking) are not exposed yet;
prompts are the supported way to read input.