15 KiB
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.
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("<info>saved</info> for <options=bold>" .. name .. "</>"))
end
Prompts
All prompts share one shape:
tui.promptXxx(text, default, opts)
text— the question shown to the user (required string).default— the prompt's default value;nilmeans none. (Exceptions:promptSelectmatches the default against the options by value;promptSecrethas no default parameter at all.)opts— an optional table of extended options, listed per prompt below. Every prompt acceptshelp(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 fortui.confirmtoo, sincefalse("answered no") is distinct fromnil("didn't answer"). - Ctrl-C raises an error (
tui.<fn>: interrupted), stopping the script unless trapped withpcall/utils.try. - A terminal is required. Prompting with stdin/stdout redirected raises
tui.<fn>: not a terminal. - Prompts are async. Like
os.sleeporhttp, a prompt suspends only the calling coroutine (see Concurrency). Prompts from concurrenttask.joinbranches are serialized — one owns the terminal at a time — butprint/logoutput from sibling coroutines can still interleave visually with a live prompt. - Validation options re-prompt inline. Constraint options like
min,maxLenorminSelectedare 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.
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; withnilthe user must type an answer.- opts:
help,placeholder.
tui.promptText(text, default, opts) → string | nil
One-line text input.
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.
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.
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—trueswitches 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—hjklnavigation.filter— setfalseto 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.
local secret = tui.promptSecret("API token", { confirm = true, minLen = 8 })
- opts:
confirm— ask twice and require both entries to match (defaultfalse).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).
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.promptIntrejects a fractional default (1.5) at call time.- opts:
placeholder,min,max(bounds enforced inline;min > maxis 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.
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.
print(tui.styled("<error> FAIL </error> expected <info>42</info>, see " ..
"<href=https://example.com/docs>the docs</> for <fg=#8888ff;options=bold>details</>"))
Built-in styles
| Tag | Rendering |
|---|---|
<info> |
bold green text |
<error> |
white on red |
<b> |
bold |
<i> |
italic |
<u> |
underscore |
<s> |
(Symfony's <comment> and <question> are deliberately not built in — add
them with tui.addPreset if you want them.)
Inline styles
<fg=COLOR>, <bg=COLOR> and <options=...> can be combined in one tag,
separated by ;:
tui.styled("<fg=green>ok</> <bg=yellow;options=bold>careful</> <fg=#c0392b>brick red</>")
- Colors:
black,red,green,yellow,blue,magenta,cyan,white,gray, theirbright-*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-<color> the background, and option names apply directly.
Shorthand mixes freely with key=value parts and built-in names:
tui.styled("<red;on-white;bold>alarm</> <#f0a;u>pink underline</> <info;b>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, -, _).
tui.addPreset("keyword", "fg=white;bg=magenta")
tui.addPreset("em", "i;bold")
print(tui.styled("the <keyword>local</keyword> keyword is <em>important</em>"))
tui.note("see the <keyword>docs</keyword>") -- 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 <info>).
Closing tags and nesting
</> closes the innermost open style; named forms (</info>,
</fg=green>) work too and do the same thing. Styles nest — an inner tag
overrides only what it sets and inherits the rest:
tui.styled("<error>fail in <options=bold>step 2</> of job</error>")
-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red
Hyperlinks
<href=URL> emits an OSC-8 hyperlink;
supporting terminals show clickable text, others show just the text:
tui.styled("see <href=https://example.com>the manual</>")
Escaping and invalid tags
A backslash escapes a literal bracket: "\\<info>" prints <info>. Anything
that does not parse as a tag — <unknown>, <Info> (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.
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 withtui.styledon 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 astyledtag body: a preset or built-in name ("error", anything fromtui.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 │
│ │
└────────────────────────────────────┘
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, defaulttrue).
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:
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;tuiis 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.styledand 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.