Lua runner with rich builtin stdlib
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
a/docs/tui.md

21 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; 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.<fn>: interrupted), stopping the script unless trapped with pcall/utils.try.
  • A terminal is required. Prompting with stdin/stdout redirected raises tui.<fn>: not a terminal.
  • Prompts are async. Like os.sleep or http, a prompt suspends only the calling coroutine (see Concurrency). 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.

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.

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).
    • multipletrue 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).
    • vimModehjkl 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.

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).

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.

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> strikethrough

(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, 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-<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

<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</>")

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:

for i = 1, 100 do
    tui.raw(tui.styled(("<clear=line>progress <info>%d%%</info>"):format(i)))
    -- ... work ...
end
tui.raw("\n")
Tag Effect
<up> <down> <left> <right> move the cursor by one cell
<up=3> move by n cells
<row=3;col=2> absolute move, 1-based; each axis works alone too
<start> column 1 of the current line
<home> top-left corner
<clear=line> clear the whole line, cursor to column 1
<clear=start> clear to the start of the line, cursor to column 1
<clear=end> clear to the end of the line, cursor stays put
<clear=screen> clear the whole screen, cursor home
<clear=up> clear above the cursor, cursor home
<clear=down> 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 (</up> is literal text). Ops combine in one tag, applied left to right: <up;clear=line> 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 <up> 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 below.

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 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                   │
 │                                    │
 └────────────────────────────────────┘
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:

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.

tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
tui.moveTo(2, 4)
tui.raw(tui.styled("<info>fullscreen!</info>"))
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 <clear=line> 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.