# `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 " .. 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>")
```
### Escaping and invalid tags
A backslash escapes a literal bracket: `"\\"` prints ``. 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.
### 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).
## 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)` | — | plain, `//` prefix |
| `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`) |
## 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.
- **More styling tags are planned** (screen clearing, cursor movement); the
markup engine is built to grow them. Progress bars can be built today from
`tui.raw` + `tui.screenInfo`.