One coherent change: make the mudlib TUI library (/std/tui) work in the browser on BOTH web terminals, harden everything the work surfaced, and pin the toolchain that broke it. ## Web terminals: xterm.js + a shared telnet client * Vendor @xterm/xterm 6.0.0 + @xterm/addon-fit 0.11.0 (dist files byte-exact from the official npm tarballs, licenses included) under src/www/vendor/. xterm.js does the terminal emulation on both pages: rendering, SGR 16/256/truecolor, alternate screen, cursor state, wide characters, scrollback, mouse reporting, bracketed paste, and keyboard encoding (the whole dialect testsuite/std/tui/keys.lpc decodes, including C-_ undo). * src/www/telnet.js -- one telnet option engine shared by both pages (transport-agnostic; hooks for page-specific options): ECHO masks the password prompt, WILL/WONT SGA -- the driver's char-mode signal (set_charmode) -- automatically switches between the line-input bar and raw keystroke streaming, NAWS reports real terminal geometry from the fit addon and re-reports on resize (driving the window_size apply), TTYPE answers xterm-256color. * src/www/wasm/index.html (the wasm shell): output/input rides xterm.js; the page keeps the synchronous-bridge queueing (sends flush outside receive() -- the wasm bridge re-enters the parser otherwise), the error modal, and the jsbridge handlers. Also guards crypto.getRandomValues() against views backed by resizable ArrayBuffers (see the emsdk section below). * src/www/index.html (the websocket client for the native driver): rewritten on the same stack, replacing a parseANSI() that stripped all cursor sequences (no TUI possible) and a telnet layer whose option bytes leaked into the text stream and never answered negotiations. Passwords now mask, char mode works, GMCP/MSP kept (dead TelnetOverWebSocket/handler classes removed); ws frames arrive as arraybuffers (no Blob/FileReader path); UTF-8 and telnet sequences survive frame splits (streaming decode + stateful parser). * tools/wasm/pack-mudlib.sh and the release zip ship vendor/ and telnet.js next to index.html in both layouts. ## Driver: websocket output wedged permanently on multi-window bursts Re-arming the writeable event from inside LWS_CALLBACK_SERVER_WRITEABLE is lossy with the libevent event lib: after the user callback returns, lws core clears POLLOUT and its pollfd bookkeeping desyncs from the evlib watcher -- the request is dropped and every later lws_callback_on_writable() no-ops, freezing output on that connection for good. First bites on any burst larger than one 2048-byte window (e.g. a full-screen TUI frame; no test had ever pushed one through a ws client). The ws_telnet.cc/ws_ascii.cc handlers now drain the evbuffer in a loop gated on lws_send_pipe_choked(); a choked write is flushed by lws's own core-managed POLLOUT path, which fires the callback again. Found by the browser end-to-end run below; documented in AGENTS.md 14. ## TUI library (/std/tui): review fixes + features Fixes: wslice() dropped combining marks from every sliced render; ESC[1;mR (modified F3) misdecoded as a cursor position report; readline lost the left scroll marker when a line overflowed both viewport edges; stray mouse events cancelled incremental search; Tab on a unique already-complete match missed the trailing space; menu lines wider than the terminal wrapped and desynced the in-place repaint (width now fed by the glue and re-fed on NAWS resize); the menu overflow indicator only showed below the window; backward focus cycling from the initial state skipped the last widget; a terminal-initiated close (disconnect, tui_destroy) leaked the app clone and its widgets -- teardown now runs through a reentry-guarded app_quit() in both directions. Features (from the README's own deferred list): readline C-_ undo (per-keystroke snapshots); pterm-style type-to-filter in select/ multiselect (results index the original choices); mouse-wheel scrolling in list/table/tree/log (wheel no longer click-selects); table clicks honour the header offset; tree Left on a leaf jumps to its parent. All pinned by testsuite/single/tests/std/tui/fixes.lpc, including the app teardown cycle via a runtime-written mock terminal. ## Toolchain: pin emsdk, guard random_get() emsdk 6.0.2 defaulted GROWABLE_ARRAYBUFFERS=1, making every ALLOW_MEMORY_GROWTH build's heap a resizable ArrayBuffer (wasmMemory.toResizableBuffer()) in browsers shipping the wasm rab-integration -- and two emscripten runtime paths pass raw HEAPU8.subarray() views into Web APIs that reject resizable-backed views: random_get() -> crypto.getRandomValues() (threw at boot) and UTF8ToString() -> TextDecoder (broke jsbridge). 6.0.3 reverted the default AND fixed the string codegen (getUnsharedTextDecoderView -> getHeapViewOrCopy), but random_get() is still unguarded upstream. Reproduced and certified against real 6.0.2/6.0.3 toolchains in Chromium (--js-flags=--experimental-wasm-rab-integration): 6.0.2 unpatched throws the exact boot error, 6.0.2 + the page's getRandomValues wrapper passes, 6.0.3 passes. CI now installs a pinned emsdk-ver input (default 6.0.3) instead of "latest". ## Verification * Native Debug+ASan: GTest 312/312; full LPC suite (574 files, per-file ref-count checker) x3 across the work; TUI test dir x3 randomized. * LPC suite inside the wasm driver under node: 5274 checks, 574 files. * Browser e2e (Playwright + Chromium): wasm shell 21/21 checks (charts/SGR, char-mode auto-switch, readline editing + undo + history, select with filter, multiselect/confirm, full-screen app, dashboard live repaint + NAWS resize relayout); websocket client against the native driver 13/13 twice on one instance (plus an ascii-subprotocol multi-window burst) -- the flow that caught the lws wedge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UvX1HbmGk9zWGBsW4Rkcq
7.2 KiB
| title |
|---|
| stdlib / tui |
The LPC TUI library (/std/tui)
The testsuite mudlib ships a terminal-UI toolkit written in pure LPC, layered
like readline + ncurses + pterm: line editing, history and incremental
search for the prompt line, inline select/multiselect/confirm prompts and
pretty-printers for ordinary output, plus a screen buffer, diff renderer and
widget set for full-screen applications. It lives in testsuite/std/tui/
and is portable to any FluffOS mudlib (copy the directory plus
include/tui.h).
The architecture document is
testsuite/std/tui/README.md;
this page is the user view.
Prompt-line editing (the readline replacement)
Inherit /std/tui/terminal into your user object (it also provides the
window_size / terminal_type applies), then replace input_to() calls
with:
void get_command() {
tui_readline((: got_command :), ([ "prompt": "> " ]));
}
void got_command(mixed line, int state) {
if (state != TUI_RL_DONE) return; // TUI_RL_ABORT (^C) / TUI_RL_EOF (^D)
do_command(line);
get_command();
}
Every prompt now has Emacs/readline editing: cursor motion (arrows,
Ctrl-A/E/B/F, word motion Alt-B/F or Ctrl-arrows), kill/yank
(Ctrl-K/U/W/Y, Alt-D), Ctrl-T transpose, Ctrl-_ undo, ↑/↓ history,
Ctrl-R/Ctrl-S incremental history search, Tab completion (pass a
"completer" function in the options), bracketed paste, masked input
("masked": 1 for passwords), wide-character (CJK) aware rendering with
horizontal scrolling, and live terminal-resize handling via NAWS. History
persists per user object across calls.
Inline prompts and printers (the pterm layer)
Three prompt helpers run in the normal output flow — no full-screen mode:
tui_select((: chose :), "Pick a class:", ({ "warrior", "mage", "thief" }));
// -> chose(int index, string item, int state)
tui_multiselect((: picked :), "Skills:", skills, ([ "height": 5 ]));
// -> picked(int *indexes, string *items, int state)
tui_confirm((: sure :), "Delete the character?", 0);
// -> sure(int yes, int state)
Arrows navigate, Space toggles, Enter accepts, Esc/Ctrl-C aborts, and
typing printable text filters the choices live (case-insensitive
substring; Backspace edits the query — result indexes always refer to the
original choices array). On completion the list collapses to a one-line
? prompt: answer record.
/std/tui/print is a set of stateless printers that compose with write():
p_table() (boxed, width-aware), p_tree(), p_bars() (bar chart),
p_spark() (sparkline), p_panel(), p_bullets(), p_header(),
p_progress(), p_info/p_success/p_warn/p_error() and p_bigtext()
(banner letters via /std/bitmap_font). Run tuidemo print to see them
all.
Charts are built on /std/tui/canvas, a braille dot canvas (2×4 dots per
cell, the blessed-contrib technique): p_chart() renders multi-series
braille line charts with a y-axis and coloured legend, p_vbars() draws
vertical bars with eighth-block partial tops, and p_heatmap() renders a
2D matrix as 256-colour cells. The same engine powers the chart widget
(add_point() rolling history — see the dashboard's traffic graph). Run
tuidemo charts to see them.
Full-screen applications
Inherit /std/tui/app, position widgets in on_layout(), and open the app
with tui_open() from the user object:
// my_app.lpc
inherit TUI_APP;
private object lst;
void on_open(object term, int w, int h) {
if (!lst) {
lst = app_add(new("/std/tui/w/list"));
lst->set_items(({ "one", "two", "three" }));
lst->set_on_event((: on_event :));
}
::on_open(term, w, h);
}
void on_layout(int w, int h) {
app_screen()->scr_clear();
app_screen()->scr_box(0, 0, w, h, TUI_BOX_ROUND, "36");
lst->set_geometry(2, 1, w - 4, h - 2);
}
// somewhere in the user object: tui_open(new("/path/to/my_app"));
The terminal switches to the alternate screen with the cursor hidden; the app
receives decoded key events (on_key), optional SGR mouse events
(on_mouse, pass (["mouse": 1]) to tui_open), and resize events
(on_resize). Tab/Shift-Tab cycle focus, Ctrl-C always quits, and
app_quit() restores the terminal.
Shipped widgets (/std/tui/w/, modeled on the pterm and blessed sets):
label, list, table (columns + header + selection), tree
(collapsible), textfield (a full readline engine per field), checklist,
radiolist, button, progress, spinner, log (bottom-anchored
scrollback pane), and chart (braille line chart with rolling history).
The widget base class (/std/tui/widget) makes new widgets ~30 lines;
tuidemo dashboard and tuidemo form show most of the set in action.
The layers (use them à la carte)
| Module | What it is |
|---|---|
/std/tui/ansi |
escape-sequence builders; visible_width() (ANSI-blind, wide-char-aware), wslice(), wpad() |
/std/tui/canvas |
braille dot canvas: sub-cell lines/plots for charts |
/std/tui/print |
pterm-style inline printers: tables, trees, charts, heatmaps, panels, banners |
/std/tui/keys |
keystroke decoder: get_char() byte stream → key events (CSI/SS3, modifiers, UTF-8, bracketed paste, SGR mouse) |
/std/tui/readline |
the line-editor engine — a pure state machine, usable headless |
/std/tui/menu |
the inline select/multiselect engine behind tui_select() |
/std/tui/screen |
virtual cell grid + minimal-diff frame renderer (the ncurses core) |
/std/tui/widget, /std/tui/w/*, /std/tui/app |
widget protocol, the widget set, application container |
/std/tui/terminal |
the only impure module: get_char loop, NAWS/TTYPE, ESC timeout, teardown |
Everything below terminal is side-effect-free and covered by the LPC
testsuite (single/tests/std/tui/). Key-event constants and readline states
are in include/tui.h (TUI_KEY_*, TUI_MOD_*, TUI_CTRL(c),
TUI_RL_*).
Try it
Boot the testsuite mudlib and connect with any terminal:
./driver testsuite/etc/config.test &
telnet localhost 4000
> tuidemo # readline demo: editing, history, ^R search, Tab completion
> tuidemo select # inline select -> multiselect -> confirm chain
> tuidemo print # the inline printers
> tuidemo charts # vertical bars, braille line chart, heatmap
> tuidemo app # minimal full-screen demo
> tuidemo dashboard # animated spinner/progress/sparkline/table/log
> tuidemo form # textfield, radio group, checkboxes, buttons
Requirements & caveats
- Works over raw telnet, the websocket
telnetsubprotocol, and the WASM bridge. The websocketasciisubprotocol has no negotiation layer (no char mode / echo control), so the library cannot run on it. - A client that never answers NAWS is assumed to be 80×24.
- The driver must not be started with both
no ansiandstrip before process inputacting on char mode — since 2026 the driver keeps ESC intact in char mode regardless of those options, so any current build is fine. - Rendering emits the VT100/xterm common subset (CSI cursor addressing, SGR,
alternate screen, bracketed paste, SGR mouse) — understood by every modern
terminal, MUD client and xterm.js.
tui_term()exposes the negotiated terminal type if an app wants to special-case.