diff --git a/Cargo.lock b/Cargo.lock
index 833a2d3..b83905c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,10 +8,13 @@ version = "0.1.0"
dependencies = [
"chrono",
"clap",
+ "colored",
+ "crossterm",
"digest_auth",
"dotenvy",
"env_logger",
"futures",
+ "inquire",
"log",
"mlua",
"reqwest",
@@ -261,6 +264,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -271,6 +283,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -306,6 +327,33 @@ dependencies = [
"libc",
]
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.0",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -348,6 +396,28 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -382,12 +452,27 @@ dependencies = [
"syn",
]
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
[[package]]
name = "either"
version = "1.16.0"
@@ -477,6 +562,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
[[package]]
name = "fd-lock"
version = "4.0.4"
@@ -597,6 +688,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -618,6 +718,17 @@ dependencies = [
"wasi",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
[[package]]
name = "h2"
version = "0.4.14"
@@ -919,6 +1030,22 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[package]]
+name = "inquire"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
+dependencies = [
+ "bitflags 2.13.0",
+ "chrono",
+ "crossterm",
+ "dyn-clone",
+ "fuzzy-matcher",
+ "tempfile",
+ "unicode-segmentation",
+ "unicode-width 0.2.2",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1051,6 +1178,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1114,6 +1247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1324,6 +1458,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "radix_trie"
version = "0.2.1"
@@ -1361,7 +1501,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom",
+ "getrandom 0.2.17",
]
[[package]]
@@ -1447,7 +1587,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom",
+ "getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -1591,7 +1731,7 @@ dependencies = [
"nix",
"radix_trie",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.1.14",
"utf8parse",
"windows-sys 0.52.0",
]
@@ -1719,6 +1859,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -1837,6 +1998,19 @@ dependencies = [
"libc",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1857,6 +2031,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -2018,6 +2201,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2168,6 +2357,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2177,6 +2382,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
[[package]]
name = "windows-core"
version = "0.62.2"
diff --git a/Cargo.toml b/Cargo.toml
index 059f307..b5671d4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,11 +6,20 @@ publish = false
[dependencies]
chrono = "0.4.45"
+# Color type + NO_COLOR/CLICOLOR/tty policy for tui.styled; the ANSI SGR
+# sequences themselves are emitted by our own renderer (see stdlib/tui/markup.rs).
+colored = "3"
env_logger = "0.11"
log = "0.4"
clap = { version = "4.6.1", features = ["derive"] }
dotenvy = "0.15"
futures = "0.3"
+# Terminal size + raw-mode backend; already in the tree as inquire's backend
+# (keep the version in lockstep with inquire's own crossterm dependency).
+crossterm = "0.29"
+# Interactive prompts for the tui module. `date` enables DateSelect (chrono),
+# `editor` the $EDITOR-based prompt; neither is in the default set.
+inquire = { version = "0.9", features = ["date", "editor"] }
mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]}
# reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS
# handshake in this environment, and native-tls would link system OpenSSL.
diff --git a/README.md b/README.md
index 29ba8dd..e2c2296 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# `a`
-`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client and SQLite, with more of the surface below planned — without additional setup.
+`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client, SQLite and interactive terminal prompts, with more of the surface below planned — without additional setup.
```sh
a file.lua # run a script
@@ -42,12 +42,13 @@ The standard library is the main purpose of `a`. Available today:
- **Networking** — HTTP client (WebSocket and MQTT are planned).
- **SQLite** — access to embedded SQLite databases.
+- **Terminal UI** — interactive prompts (confirm, text, select, password, number, date) and styled/colored output via [`tui`](docs/tui.md).
- **Core helpers** — `math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency.
Planned:
- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate.
-- **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`.
+- **Full-screen TUI** — cursor-addressed interfaces comparable to `ncurses`.
The intent is for the common case to be a single call rather than a multi-step setup.
diff --git a/docs/README.md b/docs/README.md
index 149d253..6f5d1dc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,7 @@ also works and returns the same table).
| [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. |
| [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. |
| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. |
+| [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. |
## Concepts
diff --git a/docs/tui.md b/docs/tui.md
new file mode 100644
index 0000000..b3920fe
--- /dev/null
+++ b/docs/tui.md
@@ -0,0 +1,361 @@
+# `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 |
+|-----|-----------|
+| `` | green text |
+| `` | yellow text |
+| `` | white on red |
+| `` | black on cyan |
+
+### 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), and hex `#rrggbb` / `#rgb` (truecolor).
+- Options (comma-separated): `bold`, `underscore`, `blink`, `reverse`,
+ `conceal`.
+- The syntax is strict: no spaces around `=` or `;`.
+
+### 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 built-in name (`"error"`)
+ or a spec (`"fg=black;bg=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`.
diff --git a/lua/tui_prompts.lua b/lua/tui_prompts.lua
new file mode 100644
index 0000000..8a35439
--- /dev/null
+++ b/lua/tui_prompts.lua
@@ -0,0 +1,107 @@
+-- Showcase of the tui module's interactive prompts. Requires a terminal.
+-- Esc returns nil from any prompt (handled below); Ctrl-C aborts the script.
+--
+-- Run with: cargo run -- lua/tui_prompts.lua
+
+local function banner(s)
+ print("\n=== " .. s .. " ===")
+end
+
+----------------------------------------------------------------------
+banner("1. confirm — yes/no")
+----------------------------------------------------------------------
+local go = tui.confirm("Run the whole showcase?", true, { help = "Esc anywhere skips that prompt" })
+if go == nil then
+ print("(Esc pressed — nil means the user backed out, false means they answered no)")
+elseif not go then
+ return
+end
+
+----------------------------------------------------------------------
+banner("2. promptText — defaults, placeholder, suggestions")
+----------------------------------------------------------------------
+-- The positional default is pre-filled and editable in place.
+local name = tui.promptText("Your name?", "ondra") or "anonymous"
+
+-- suggestions: type to filter (case-insensitive substring), Tab completes.
+-- opts.default (unlike the positional one) is only used on empty submit.
+local editor = tui.promptText("Favorite editor?", nil, {
+ placeholder = "type to autocomplete",
+ suggestions = { "vim", "neovim", "emacs", "helix", "vscode", "zed" },
+ default = "vim",
+})
+
+-- minLen/maxLen re-prompt inline until satisfied.
+local tag = tui.promptText("Release tag? (3-10 chars)", nil, { minLen = 3, maxLen = 10 })
+
+----------------------------------------------------------------------
+banner("3. promptSelect — single and multiple choice")
+----------------------------------------------------------------------
+-- Single: returns the chosen string; the default sets the cursor.
+local lang = tui.promptSelect("Favorite language?", "lua", {
+ options = { "lua", "rust", "c", "python", "javascript" },
+ help = "type to filter the list",
+})
+
+-- Multiple: space toggles, enter submits; returns an array of strings.
+local toppings = tui.promptSelect("Pizza toppings?", { "cheese" }, {
+ options = { "cheese", "ham", "mushrooms", "olives", "pineapple" },
+ multiple = true,
+ minSelected = 1,
+ pageSize = 4,
+})
+
+----------------------------------------------------------------------
+banner("4. Numbers and dates")
+----------------------------------------------------------------------
+-- promptNumber accepts any number, promptInt only whole numbers; both
+-- re-prompt inline when the input doesn't parse or is out of range.
+local ratio = tui.promptNumber("Quality (0-1)?", 0.8, { min = 0, max = 1 })
+local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 })
+
+-- promptDate opens a calendar; dates are YYYY-MM-DD strings both ways.
+local day = tui.promptDate("Release day?", os.date("%Y-%m-%d"), {
+ min = os.date("%Y-01-01"),
+ max = os.date("%Y-12-31"),
+ weekStart = "monday",
+})
+
+----------------------------------------------------------------------
+banner("5. promptSecret — hidden input")
+----------------------------------------------------------------------
+-- No default parameter on purpose. display: "masked" (default) shows *,
+-- "hidden" echoes nothing, "full" shows the text; toggle allows Ctrl-R.
+local secret = tui.promptSecret("API token?", { minLen = 4, toggle = true })
+
+----------------------------------------------------------------------
+banner("6. promptLongText — opens $EDITOR")
+----------------------------------------------------------------------
+local notes
+if tui.confirm("Open your editor for release notes?", false) then
+ notes = tui.promptLongText("Release notes", "## " .. (tag or "next") .. "\n\n- ", { extension = ".md" })
+end
+
+----------------------------------------------------------------------
+banner("Summary")
+----------------------------------------------------------------------
+local function show(k, v)
+ if v == nil then
+ v = "(skipped)"
+ elseif type(v) == "table" then
+ v = table.concat(v, ", ")
+ end
+ print(tui.styled(" " .. k .. ": " .. tostring(v)))
+end
+
+show("name", name)
+show("editor", editor)
+show("tag", tag)
+show("language", lang)
+show("toppings", toppings)
+show("quality", ratio)
+show("port", port)
+show("release day", day)
+show("token", secret and string.rep("*", #secret))
+show("notes", notes and (#notes .. " chars"))
+
+tui.success("Showcase finished — see lua/tui_styles.lua for the output half of the module")
diff --git a/lua/tui_styles.lua b/lua/tui_styles.lua
new file mode 100644
index 0000000..3e8e707
--- /dev/null
+++ b/lua/tui_styles.lua
@@ -0,0 +1,79 @@
+-- Showcase of the tui module's output features: styled markup, message
+-- blocks, outline blocks, and the raw/screenInfo building blocks.
+-- Non-interactive — safe to run piped (colors degrade to plain text).
+--
+-- Run with: cargo run -- lua/tui_styles.lua
+
+local function banner(s)
+ print("\n=== " .. s .. " ===")
+end
+
+----------------------------------------------------------------------
+banner("1. tui.styled — built-in tags")
+----------------------------------------------------------------------
+print(tui.styled("info is green, comment is yellow"))
+print(tui.styled(" error: white on red question: black on cyan "))
+
+----------------------------------------------------------------------
+banner("2. Inline styles: fg / bg / options")
+----------------------------------------------------------------------
+print(tui.styled("named colors>, bright variants>, gray>"))
+print(tui.styled("hex truecolor #c0392b> and short #0af>"))
+print(tui.styled("background> bold> underscore>"))
+print(tui.styled(" all combined in one tag >"))
+
+----------------------------------------------------------------------
+banner("3. Nesting, links, escaping")
+----------------------------------------------------------------------
+-- Inner tags override only what they set; the rest is inherited.
+print(tui.styled("outer bold inherits the red bg> and back"))
+-- > closes the innermost style; fg=default resets to the terminal color.
+print(tui.styled("red terminal default> red again>"))
+-- OSC-8 hyperlink (clickable in supporting terminals, plain text elsewhere).
+print(tui.styled("read the docs> for details"))
+-- \< escapes a bracket; unknown tags pass through literally, never error.
+print(tui.styled("literal \\ stays, too, a < b as well"))
+
+----------------------------------------------------------------------
+banner("4. Message blocks (Symfony style)")
+----------------------------------------------------------------------
+tui.success("Deployment finished without errors")
+tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" })
+tui.error("Build failed")
+tui.caution("This will overwrite production data")
+tui.info("14 files processed")
+tui.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label")
+tui.comment("git push --force-with-lease")
+-- The generic form: everything is configurable.
+tui.block(
+ "Custom label, style and prefix",
+ { label = "HEY", style = "fg=black;bg=cyan", prefix = " > ", padding = true }
+)
+
+----------------------------------------------------------------------
+banner("5. Outline blocks (colored borders, plain content)")
+----------------------------------------------------------------------
+tui.outlineSuccess("All tests passed")
+tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset
+tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank line." }, {
+ title = "Stats",
+ style = "fg=magenta",
+})
+
+----------------------------------------------------------------------
+banner("6. tui.raw + tui.screenInfo — progress bar building blocks")
+----------------------------------------------------------------------
+local info = tui.screenInfo()
+print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors)
+
+-- A minimal in-place progress bar: \r rewinds the line, tui.raw prints
+-- without a newline and flushes. Width adapts to the terminal; without a
+-- tty the \r redraw trick is pointless, so print the final state once.
+local width = math.clamp((info.width or 80) - 20, 10, 40)
+local start = info.tty and 0 or width
+for i = start, width do
+ local bar = string.rep("#", i) .. string.rep(".", width - i)
+ tui.raw("\r[", bar, "] ", math.floor(i / width * 100 + 0.5), "%")
+ os.sleep(0.02)
+end
+tui.raw("\n")
diff --git a/src/lua_tests/mod.rs b/src/lua_tests/mod.rs
index ee9b9c7..87fa350 100644
--- a/src/lua_tests/mod.rs
+++ b/src/lua_tests/mod.rs
@@ -7,6 +7,7 @@ mod math_tests;
mod os_tests;
mod require_tests;
mod table_tests;
+mod tui_tests;
mod utils_tests;
use mlua::Lua;
diff --git a/src/lua_tests/tui_tests.rs b/src/lua_tests/tui_tests.rs
new file mode 100644
index 0000000..01484ff
--- /dev/null
+++ b/src/lua_tests/tui_tests.rs
@@ -0,0 +1,326 @@
+//! Tests for the tui module: `tui.styled` behavior and the prompt argument
+//! validation that fires *before* the terminal is touched (so it's
+//! deterministic in CI, tty or not). The interactive halves of the prompts
+//! are exercised manually — see docs/tui.md.
+
+use super::lua;
+
+// ---------------------------------------------------------------------------
+// module surface
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_tui_global_has_all_functions() {
+ let lua = lua();
+ for f in [
+ "confirm",
+ "promptText",
+ "promptLongText",
+ "promptSelect",
+ "promptSecret",
+ "promptNumber",
+ "promptInt",
+ "promptDate",
+ "styled",
+ "block",
+ "success",
+ "error",
+ "warning",
+ "caution",
+ "info",
+ "note",
+ "comment",
+ "outlineBlock",
+ "outlineSuccess",
+ "outlineError",
+ "outlineWarning",
+ "outlineNote",
+ "outlineInfo",
+ "outlineCaution",
+ "raw",
+ "screenInfo",
+ ] {
+ let is_fn: bool = lua
+ .load(format!("return type(tui.{f}) == 'function'"))
+ .eval()
+ .unwrap();
+ assert!(is_fn, "tui.{f} is missing or not a function");
+ }
+}
+
+#[test]
+fn test_require_returns_the_global() {
+ let lua = lua();
+ // require needs roots installed; builtins resolve without touching the fs.
+ crate::stdlib::require::install(&lua, vec![]).unwrap();
+ let same: bool = lua.load(r#"return require("tui") == tui"#).eval().unwrap();
+ assert!(same);
+}
+
+// ---------------------------------------------------------------------------
+// tui.styled
+// ---------------------------------------------------------------------------
+
+// NOTE: whether escape codes are emitted depends on the test process's
+// stdout (colored's tty/NO_COLOR detection), so these assertions only check
+// properties that hold in both color modes. Exact byte output is pinned by
+// the unit tests in stdlib/tui/markup.rs, which bypass the global detection.
+
+#[test]
+fn test_styled_returns_payload_text() {
+ let lua = lua();
+ let out: String = lua
+ .load(r#"return tui.styled("a b c> d")"#)
+ .eval()
+ .unwrap();
+ // Tags are consumed, payload text survives in order.
+ assert!(!out.contains(""), "tag not consumed: {out:?}");
+ assert!(!out.contains("fg=red"), "tag not consumed: {out:?}");
+ let stripped: String = out.chars().filter(|c| " abcd".contains(*c)).collect();
+ assert_eq!(stripped, "a b c d");
+}
+
+#[test]
+fn test_styled_resolves_escapes_and_keeps_invalid_tags() {
+ let lua = lua();
+ let out: String = lua
+ .load(r#"return tui.styled("\\x y")"#)
+ .eval()
+ .unwrap();
+ assert!(out.contains("x"), "escape not resolved: {out:?}");
+ assert!(out.contains(""), "invalid tag should stay literal: {out:?}");
+}
+
+#[test]
+fn test_styled_rejects_non_string() {
+ let lua = lua();
+ let err = lua.load(r#"return tui.styled(42)"#).eval::().unwrap_err();
+ assert!(err.to_string().contains("tui.styled:"), "{err}");
+}
+
+// ---------------------------------------------------------------------------
+// prompt argument validation (fires before any terminal interaction)
+// ---------------------------------------------------------------------------
+
+/// Run a prompt call and assert it fails with a message carrying `expect`.
+async fn assert_prompt_error(src: &str, expect: &str) {
+ let lua = lua();
+ let err = lua.load(src).exec_async().await.unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
+}
+
+#[tokio::test]
+async fn test_prompt_text_arg_validation() {
+ assert_prompt_error("tui.confirm(42)", "tui.confirm: prompt text must be a string (got integer)")
+ .await;
+ assert_prompt_error("tui.confirm('q', 'yes')", "tui.confirm: default must be a boolean").await;
+ assert_prompt_error(
+ "tui.promptText('q', 42)",
+ "tui.promptText: default must be a string (got integer)",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_unknown_and_mistyped_options() {
+ assert_prompt_error(
+ "tui.promptText('q', nil, {placholder='x'})",
+ "tui.promptText: unknown option 'placholder' (allowed: ",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.confirm('q', nil, {help=42})",
+ "tui.confirm: option 'help' must be a string (got integer)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptText('q', nil, {maxLen='five'})",
+ "tui.promptText: option 'maxLen' must be a non-negative integer",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptText('q', nil, {minLen=5, maxLen=2})",
+ "tui.promptText: min (5) must not be greater than max (2)",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_select_validation() {
+ assert_prompt_error(
+ "tui.promptSelect('q')",
+ "tui.promptSelect: opts table with 'options' is required",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', nil, {})",
+ "tui.promptSelect: opts table with 'options' is required",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', nil, {options={}})",
+ "tui.promptSelect: options must not be empty",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', nil, {options={'a', {}}})",
+ "tui.promptSelect: options[2] must be a string (got table)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', 'nope', {options={'a','b'}})",
+ "tui.promptSelect: default 'nope' is not one of the options",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', {'a'}, {options={'a','b'}})",
+ "tui.promptSelect: default must be a string (got table)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', {'a','nope'}, {options={'a','b'}, multiple=true})",
+ "tui.promptSelect: default 'nope' is not one of the options",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptSelect('q', nil, {options={'a'}, minSelected=1})",
+ "tui.promptSelect: option 'minSelected' requires multiple = true",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_password_and_number_validation() {
+ assert_prompt_error(
+ "tui.promptSecret('q', {display='partial'})",
+ "tui.promptSecret: option 'display' must be one of 'masked', 'hidden', 'full' (got 'partial')",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptNumber('q', 'five')",
+ "tui.promptNumber: default must be a number (got string)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptNumber('q', nil, {min=10, max=1})",
+ "tui.promptNumber: min (10) must not be greater than max (1)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptInt('q', 1.5)",
+ "tui.promptInt: default must be an integer (got 1.5)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptInt('q', nil, {min=1.5})",
+ "tui.promptInt: option 'min' must be an integer (got 1.5)",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_date_validation() {
+ assert_prompt_error(
+ "tui.promptDate('q', 'tomorrow')",
+ "tui.promptDate: default must be a YYYY-MM-DD string (got 'tomorrow')",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptDate('q', nil, {min='2026-02-30'})",
+ "tui.promptDate: option 'min' must be a YYYY-MM-DD string",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptDate('q', nil, {min='2026-06-01', max='2026-01-01'})",
+ "tui.promptDate: min (2026-06-01) must not be greater than max (2026-01-01)",
+ )
+ .await;
+ assert_prompt_error(
+ "tui.promptDate('q', nil, {weekStart='someday'})",
+ "tui.promptDate: option 'weekStart' must be a weekday name like 'monday' (got 'someday')",
+ )
+ .await;
+}
+
+// ---------------------------------------------------------------------------
+// blocks / raw / screenInfo
+// ---------------------------------------------------------------------------
+// The block functions print to stdout; their rendering is pinned by unit
+// tests in stdlib/tui/blocks.rs. Here we exercise argument validation and
+// the informational surface.
+
+#[test]
+fn test_block_validation() {
+ let lua = lua();
+ for (src, expect) in [
+ ("tui.success(42)", "tui.success: message must be a string or an array of strings (got integer)"),
+ ("tui.block({})", "tui.block: message must not be empty"),
+ ("tui.block({'a', 1})", "tui.block: message[2] must be a string (got integer)"),
+ ("tui.block('x', {style='notastyle'})", "tui.block: invalid style 'notastyle'"),
+ ("tui.outlineBlock('x', {label='X'})", "tui.outlineBlock: unknown option 'label'"),
+ ("tui.note('x', {title='X'})", "tui.note: unknown option 'title'"),
+ ] {
+ let err = lua.load(src).exec().unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
+ }
+}
+
+#[test]
+fn test_block_functions_print_without_error() {
+ let lua = lua();
+ // Not asserting on stdout (captured by the test harness); this pins that
+ // valid calls succeed for both shapes and with option overrides.
+ lua.load(
+ r#"
+ tui.success("all good")
+ tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true})
+ tui.outlineError("boom")
+ tui.outlineBlock("custom", {title="Box", style="comment", padding=false})
+ tui.raw("progress ", 42, "\r")
+ "#,
+ )
+ .exec()
+ .unwrap();
+}
+
+#[test]
+fn test_screen_info_shape() {
+ let lua = lua();
+ let (tty, colors, width_type): (bool, bool, String) = lua
+ .load(
+ r#"
+ local i = tui.screenInfo()
+ return i.tty, i.colors, type(i.width)
+ "#,
+ )
+ .eval()
+ .unwrap();
+ // Under cargo test stdout is not a terminal; just pin the field types.
+ let _ = (tty, colors);
+ assert!(width_type == "number" || width_type == "nil", "width: {width_type}");
+}
+
+/// Without a terminal, an actual prompt attempt must fail — with the
+/// function's error prefix, not a panic. The exact wording (NotTTY vs an IO
+/// error) varies by environment, so only the prefix is asserted.
+///
+/// Skipped when `cargo test` has any way to reach a terminal: the prompt
+/// would then really render (garbling the runner output with raw-mode
+/// artifacts) and hang waiting for a key. That includes redirected runs in an
+/// interactive shell — crossterm falls back to `/dev/tty` when stdin is not a
+/// terminal. Only truly terminal-less environments (CI) exercise this.
+#[tokio::test]
+async fn test_prompting_off_tty_errors_with_prefix() {
+ use std::io::IsTerminal;
+ let has_tty = std::io::stdin().is_terminal()
+ || std::io::stderr().is_terminal()
+ || std::fs::File::open("/dev/tty").is_ok();
+ if has_tty {
+ return;
+ }
+ let lua = lua();
+ let err = lua.load("tui.confirm('proceed?')").exec_async().await.unwrap_err();
+ assert!(err.to_string().contains("tui.confirm:"), "{err}");
+}
diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs
index 2fd3154..497935b 100644
--- a/src/stdlib/mod.rs
+++ b/src/stdlib/mod.rs
@@ -9,6 +9,7 @@ pub(crate) mod require;
mod sqlite;
mod table;
mod task;
+mod tui;
pub(crate) mod utils;
pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
@@ -20,5 +21,6 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
sqlite::install(lua)?;
http::install(lua)?;
task::install(lua)?;
+ tui::install(lua)?;
Ok(())
}
diff --git a/src/stdlib/require.rs b/src/stdlib/require.rs
index 514f865..89654a2 100644
--- a/src/stdlib/require.rs
+++ b/src/stdlib/require.rs
@@ -29,6 +29,7 @@ const BUILTINS: &[&str] = &[
"sqlite",
"http",
"task",
+ "tui",
"coroutine",
"math",
"os",
diff --git a/src/stdlib/tui/blocks.rs b/src/stdlib/tui/blocks.rs
new file mode 100644
index 0000000..0194a97
--- /dev/null
+++ b/src/stdlib/tui/blocks.rs
@@ -0,0 +1,291 @@
+//! SymfonyStyle-like message blocks: `[LABEL]`-prefixed colored blocks
+//! (`tui.success`, `tui.error`, …) and outlined boxes (`tui.outlineBlock`
+//! and its presets).
+//!
+//! Rendering is pure — `render_block`/`render_outline_block` take the line
+//! length and a `colorize` flag and return finished lines — so unit tests pin
+//! exact output; the Lua bindings in `mod.rs` supply the real terminal width
+//! and color policy and do the printing.
+//!
+//! Message text is treated as **plain text** here (matching Symfony's blocks,
+//! which escape their input): it is word-wrapped and padded by character
+//! count, and the single block style is applied to whole lines. Rendering
+//! wrapped text that itself contains markup is not supported — compose with
+//! `tui.styled` output on your own lines instead.
+
+use super::markup::{self, Style};
+
+/// Blocks never grow wider than this, even on wide terminals (Symfony's
+/// MAX_LINE_LENGTH).
+pub(super) const MAX_LINE_LENGTH: usize = 120;
+
+pub(super) struct BlockCfg {
+ pub label: Option,
+ pub style: Option