tuimark

package module
v0.4.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: MIT Imports: 9 Imported by: 0

README

Tuimark

CI Go Reference

Tuimark is a view language for terminals. UI is authored as text — structure in .tui (XML), style in .tcss (a reduced CSS), data as JSON, behavior as named actions (on:select="open") — and a Go runtime interprets those files: it lays out an integer cell grid, paints Unicode, and dumps a machine-readable snapshot.

agent edits app.tui + theme.tcss + sample.json
        ↓
tuimark dump --format json --cols 80
        ↓
grid + node geometry
        ↓
agent edits again

Website: tuimark.dev

  • Getting started, the guides, and the reference for the language, the CLI, the Go API, and the TypeScript client
  • Examples: every app in examples/, pictured from tuimark play output, with animated demos
  • Kit components: every component of the kit, pictured in both themes, with its markup
  • Blog (RSS)
  • For agents: every page as Markdown (its URL plus .md), llms.txt, and llms-full.txt

Release history is in the Changelog.

The Tuimark specification (the SPEC, currently v0.3) is the source of truth for the language and the runtime. It is maintained by the author outside this repository; "SPEC §N" references in code and docs point to it. This file is the map of what's in the repo and how to drive it.

What Tuimark is not

Rules the runtime holds to (SPEC §1):

  • Not a compiler. .tui is interpreted at runtime; nothing generates Go.
  • Not Bubble Tea, Lipgloss, tview, Textual, Ink, or Ratatui — none of those are used as the authoring surface or the public API.
  • Not pixels. One unit is one terminal cell; there is no px/em/vh.
  • Not an open vocabulary. Tags and CSS properties are a closed catalog; an unknown one is a diagnostic (V001/V003), not a new widget.
  • Not a place for logic. No expressions, filters, function calls, or Go in markup — only literal values, {path} interpolation, each, and if.

Installation

Homebrew (macOS and Linux)
brew install --cask abdul-hamid-achik/tap/tuimark
tuimark version

The tap is added automatically; you do not need to run brew tap first.

npm (Node and Bun)
npm i -g @thelacanians/tuimark
tuimark version

Or run it once without installing: npx @thelacanians/tuimark version.

@thelacanians/tuimark is the CLI and the TypeScript client in one package (sdk/ts in this repository). Its tuimark command is a small launcher (plain JavaScript for Node 18 or later and Bun, no dependencies, no postinstall script) that runs the Go binary of the release with the same version: it downloads tuimark_<version>_<os>_<arch> from the GitHub release once, checks its SHA-256 against a table built into the package, and caches it. TUIMARK_BINARY points it at a binary you installed yourself. See TypeScript client for the library side.

Go
go install github.com/abdul-hamid-achik/tuimark/cmd/tuimark@latest

This puts tuimark in $(go env GOPATH)/bin (make sure that directory is on your PATH). It needs Go 1.22 or newer.

Release archives

Each GitHub release has prebuilt binaries for macOS, Linux, and Windows on amd64 and arm64: tuimark_<version>_<Os>_<arch>.tar.gz (.zip on Windows), each holding the tuimark binary, this README, the changelog, and the license. Check the download against checksums.txt, unpack it, and put tuimark on your PATH:

shasum -a 256 -c checksums.txt --ignore-missing   # or: sha256sum -c --ignore-missing checksums.txt
tar -xzf tuimark_*_Linux_x86_64.tar.gz tuimark
sudo install tuimark /usr/local/bin/
Go library

The runtime is also a Go package (see Public Go API):

go get github.com/abdul-hamid-achik/tuimark@latest
import "github.com/abdul-hamid-achik/tuimark"

It has no CGO and needs Go 1.22 or newer. On current macOS, a program built with Go 1.22 that links the net package (such as examples/agent) fails to start with dyld: missing LC_UUID load command; build it with Go 1.23 or newer there.

TypeScript client

The same npm package drives a view from a Bun program over the host protocol: spawnHost starts tuimark host with your document, and your program writes data and answers the actions the document fires.

bunx @thelacanians/tuimark new my-app --ts   # a project with a Bun parent
npm i @thelacanians/tuimark                  # or add it to your own
import { spawnHost } from "@thelacanians/tuimark";

const ui = await spawnHost("app.tui", { data: "sample.json" });
ui.on("open", async (ev) => {
  await ui.set("detail", await load(ev.keys.item));
});

The client needs Bun 1.4.2 or later. See the TypeScript client guide and examples/host-ts.

From source
git clone https://github.com/abdul-hamid-achik/tuimark.git
cd tuimark
go build -o bin/tuimark ./cmd/tuimark

task build (below) builds the CLI and every example host into bin/.

Requirements (development)

  • Go 1.22+, no CGO (internal/layout and internal/paint are the only layout/paint engines involved; there is no C dependency to build).
  • go-task — optional, for the task verify / task build / task glyph shortcuts in Taskfile.yml. Each task is a short shell command; without task, use the equivalent commands shown under Testing (for example the five go build lines), since a couple of tasks use Taskfile template variables (like {{.AGENT_DIR}}) that a plain shell won't expand.
  • Glyphrun (the glyph CLI) — optional, only needed to run the terminal end-to-end specs under specs/glyphrun/. go install github.com/abdul-hamid-achik/glyphrun/cmd/glyph@latest (it needs Go 1.26; CI pins @v0.22.0), then run glyph docs for its own documentation.

Quickstart

go build -o bin/tuimark ./cmd/tuimark
./bin/tuimark validate examples/spike/inbox.tui
./bin/tuimark dump examples/spike/inbox.tui --cols 80 --rows 24
./bin/tuimark dump examples/spike/inbox.tui --cols 80 --rows 24 --format json
./bin/tuimark preview examples/spike/inbox.tui --cols 80 --rows 24

dump prints one frame: the text dump by default, or --format json for the machine-readable shape (grid + nodes + errors). preview writes the same text dump, plus an ANSI-painted grid first when stdout is a TTY. Bind a JSON file as the data store with --data:

./bin/tuimark dump examples/inbox/app.tui --data examples/inbox/sample.json --cols 80 --rows 24

To use the document live in the terminal, with no host program, run it: every action it fires shows in an event strip (F12, or --strip from the start), and saving the file reloads it in place.

./bin/tuimark run examples/inbox/app.tui --data examples/inbox/sample.json --strip

Or use it as a library:

ui, err := tuimark.Load("app.tui")
if err != nil { return err }
_ = ui.Bind("tickets", tickets)
ui.On("open", handleOpen)
ui.On("quit", func(tuimark.Event) error { return tuimark.ErrQuit })
return ui.Run(os.Stdout)

Language tour

A minimal complete app: one list bound to an array, one detail pane bound to the selected item, and a media query that stacks them on a narrow terminal. Three files:

app.tui:

<tui theme="dark">
  <style src="theme.tcss"/>
  <keymap>
    <bind keys="q,ctrl+c" action="quit"/>
  </keymap>
  <screen id="main" focus="#items">
    <col id="root">
      <row id="header">
        <text>{title}</text>
      </row>
      <row id="body">
        <list id="items" each="tickets as t" key="t.id" bind="selected" on:select="open">
          <item>
            <text>{t.title}</text>
          </item>
        </list>
        <box id="detail" if="selected_ticket">
          <text>{selected_ticket.title}</text>
        </box>
        <box id="empty" if="!selected_ticket">
          <text>no selection</text>
        </box>
      </row>
    </col>
  </screen>
</tui>

theme.tcss:

#header { height: 1; }
#body { layout: row; gap: 1; }
#items { width: 30%; border: single; }
#detail, #empty { width: 1fr; border: single; }

@media (max-cols: 60) {
  #body { layout: column; }
  #items { width: 100%; height: 8; }
}

sample.json:

{
  "title": "tickets",
  "selected": "t-1",
  "selected_ticket": { "id": "t-1", "title": "fix the login loop" },
  "tickets": [
    { "id": "t-1", "title": "fix the login loop" },
    { "id": "t-2", "title": "cannot deploy" }
  ]
}
./bin/tuimark validate app.tui --data sample.json
./bin/tuimark dump app.tui --data sample.json --cols 80 --rows 24
./bin/tuimark dump app.tui --data sample.json --cols 60 --rows 24

validate reports ok, and the two dumps show the same layout side by side at 80 columns and stacked at 60 (the @media (max-cols: 60) block takes over), proving the loop end to end. This one example touches:

  • Layout units (SPEC §9): N (height: 1, height: 8), N% (width: 30%), Nfr (width: 1fr, the implicit 1fr on col/row), and auto (the default for text).
  • TCSS selectors/@media (SPEC §10): #id and a comma-list of ids as selectors, and one feature per @media block (max-cols).
  • Bindings (SPEC §7): {path} in <text> bodies, each="tickets as t" on <list>, and if="selected_ticket" / if="!selected_ticket".
  • Keymap and actions (SPEC §8): a global <bind> for quit, and on:select="open" as a named action the host would register with On.

Document versions

A <tui> needs no version: without one it is the current language, with everything this section describes plus the 0.4b additions in the Language notes below (composition, and a few defaults and key rules). It is not version="3": a document that writes version="1", "2", or "3" is pinned to the meaning it had in 0.4.1, defaults included, and its dumps do not change; new vocabulary in a pinned document is a diagnostic that ends (requires a <tui> without version). There is no version="4" (it is V003). A version="1" document keeps the vocabulary and meaning it has always had, and its dumps stay byte-identical. version="2" adds the 0.2b vocabulary (SPEC §5.1), and version="3" the 0.3 one. This build implements all of it:

  • The version gate. In a version="1" document each 0.2b tag is V001, each 0.2b attribute V002, and each 0.2b value, property, pseudo-class, media feature, or built-in action V003, with a message that ends in (requires version="2"). A .tcss file is checked against the version of the document that loads it, so one stylesheet can serve both. Any other version is V003, and the document is read as version="1". Removing the attribute lifts the gate.

  • class:NAME="path" (or !path) on any rendered element adds the class NAME while the guard is truthy, evaluated every frame in the element's scope (a missing path is B002 and counts as null, as for if: class:x="path" adds nothing, class:x="!path" adds x). NAME is lowercase, [a-z_][a-z0-9_-]* (V015 otherwise; an uppercase letter is V002). The JSON dump of a version="2" document lists each node's classes: its class names, then its truthy guards, without repeats (a name that is already there, from class or a guard, is not added again, so class="a b a" gives a b).

  • theme="auto". Run() asks the terminal for its background color (OSC 11, waiting at most 250 ms, so the first frame is already in the right theme), falls back to COLORFGBG, then to dark. Every command and Dump()/Validate() treat auto as dark, and validate checks an auto document under both themes.

  • @media (theme: dark) / @media (theme: light) hold per-theme rules and :root palettes. Tokens apply in document order, so a light block goes after the base :root it refines:

    :root { --brand: #e0443e; }
    @media (theme: light) { :root { --brand: #c8102e; --bg: #fbfaf8; } }
    #title { color: $brand; }
    
  • :focus-within matches the focused node and each ancestor up to the screen (through a modal); nothing while nothing is focused. :checked matches a list or table row whose key is in the widget's checked array. Both count +10, like the other pseudo-classes.

  • <table> and <column>. A table shows one row per element of an array, in columns. Each <column>'s body is its cell template, resolved on each row; its title is the header text. At most one attribute-only <item class="…" class:NAME="…"/> is the row template.

    <table id="procs" each="procs as p" key="p.pid" bind="cursor_pid"
           checked="marked" mark="▸" placeholder="no results" on:select="moved">
      <item class:blocked="p.protected"/>
      <column id="c-pid" class="num" title="PID" width="7">{p.pid}</column>
      <column id="c-name" title="Name" width="1fr">{p.name}</column>
      <column id="c-cpu" class="num" title="CPU%" class:hot="p.cpu_hot">{p.cpu}</column>
    </table>
    

    It is focusable and keeps a cursor like a list: it consumes up, down, home, end, pgup, and pgdn while it has rows (a page is its body height), writes the cursor row's key, with its JSON type, to bind, and fires on:select with keys = {alias: key}. The bound value selects the first row whose key equals it (7 and "7" differ); when none does, the cursor keeps its index. The scroll offset follows the cursor whatever moved it (a key, a Set, a re-sort, a resize), and only the rows in the body viewport are laid out, painted, and dumped: scroll.h is the row count, the header excluded. Inside a <scroll>, an unsized table lays out every row. Column widths: width in cells; % of the width left after the mark channel; fr shares what is left and is then clamped by min-width/max-width; unset or auto is the widest of the title and the cell texts on every row. Columns that do not fit are clipped; display: none on a column (under @media, say) hides its header and its cells. The header row is shown when a visible column has a title attribute, and the column nodes are its cells in the dump (their text is the resolved title). Cells never wrap: wrap: wrap truncates. A rule on a column styles its header cell only; body cells are table > item > text nodes without an id that carry the column's class plus its class:NAME guards evaluated on their row, so .num { content-align: end; } aligns a whole column and #procs > item > text.hot colors only the hot cells. Rows and cells ignore their own sizes, borders, padding, and margins. placeholder is painted dim and centered on the first body row when the array is empty (it is not a node); an unsized empty table with a placeholder keeps that one body row (and is at least as wide as the text), so it never auto-fits down to its header and hides it. checked/mark and the move-*/check-* built-ins work as on a list. Diagnostics: V012 without id, V017 without each or a column, V016 for misplaced children or text, B006 without key, L003 when the columns' cell and % widths and min-widths exceed the table, L006 for a body of 0 rows that has rows. Cell and guard paths are resolved on every row and reported once per path, so the diagnostics never depend on the scroll offset; the resolved rows are cached until a store path they read is set again, so every change to the array costs one pass over its rows whatever the column widths. The bound cursor row and the checked keys are looked up in key sets kept with the rows and the checked array, so check-all is linear and a frame with thousands of checked rows costs what one with none does.

  • scrollbar: auto on a viewport (scroll, list, table, or an overflow: scroll box) that scrolls on y, has a border, is at least 3 rows tall, and has more content than fits paints a thumb over its right border: ┃ on single/rounded borders, █ on double/thick. With track = h − 2, length = max(1, track·view/content) and pos = (track − length)·offset/(content − view) (integer division; for a table, view is its body height and content its rows). It never takes layout space. scrollbar: none is the default.

  • bar: eighths on a progress fills eighths of a cell: e = floor(W·8·v/100 + 0.5) gives e div 8 full cells and the partial glyph ▏▎▍▌▋▊▉ number e mod 8; bar: block, the default, is the v1 bar.

  • <tabs> and <tab>. A tabs paints a one-row strip of labels and, right below it, the content of its active tab; inactive tabs are never inflated or laid out, and widgets inside them keep their state by id (list cursors, input text, scroll offsets, a nested tabs' tab).

    <tabs id="nav" bind="view" mark="▸" gap="2" on:select="view_changed">
      <tab id="overview" label="1 overview" short="1 ovr"> … </tab>
      <tab id="processes" label="7 processes" short="7 proc" focus="#procs"> … </tab>
    </tabs>
    

    With bind, the active tab is the visible tab whose id is the bound string (null or a missing path, B003, give the first visible tab; any other value gives it too and reports B010; fallbacks never write the store); without bind, the runtime remembers the tab per tabs id. A user activation (switch-to, move-next/move-prev/move-first/ move-last on the tabs, left/right on a focused focusable="true" tabs, action="focus" into an inactive tab) writes the tab's id to bind (or remembers it) and fires on:select with value = the id; a host Set activates without on:select. A focus request into a nested tab that is already its tabs' active tab by a fallback does not activate it: nothing is written and its on:select does not fire. Disabled tabs are skipped by those keys and actions. Labels come in three tiers so the strip always fits: every label when they fit (with gap between them and a mark slot of width(mark) in front of each), else every short (an empty short counts as absent), else the active tab's alone as ‹ label › (then ‹ short ›, then truncated). Labels are generated text nodes (class tab-label, key = the tab id, :selected on the active one, :disabled on a disabled tab), styled with .tab-label or tabs > text; they ignore their own sizes, spacing, borders, and display. When the active tab changes and focus was on the strip, inside the old tab, or nowhere, focus moves to the new tab's focus= target, else its first focusable node, else the screen's rule; the on:select comes first, then that node's on:focus. When one frame changes several tabs while focus is nowhere, only the first in document order moves it. Focus elsewhere stays. On a screen's first frame, screen@focus wins, then the first tabs whose active tab has a focus= that can take focus. A tabs and every tab need an id (V012), a tabs needs a tab (V017) and holds only tabs (V016), a tab needs a non-empty label (V003), and a tab focus= must name a node inside it (B005).

  • <sparkline bind="cpu.hist" min="0" max="100"/> draws the last values of a numeric array as bars, one column per value, eight levels per row, right-aligned in its box (null is a gap). Without min/max the smallest and largest shown values are the range; a flat series sits at half height. It is as wide as the array by default and one row high (give it a height for more levels). A value that is not an array, or an element that is neither a number nor null, is B009.

  • <hints> shows key hints generated from the keymap: a <bind> with a label is a hint row, and keycap replaces the key text shown (an empty keycap counts as absent). scope="active" (the default) shows the rows that one of their keys would fire right now, through the same key dispatch Run() and play use: a key the focused input takes, a row shadowed by an earlier row with the same key, an esc row under a modal with on:escape, and a built-in whose target does not match are all hidden. scope="all" shows every hint row (a help screen). Items are laid out along the row (or down, with layout: column), gap apart; an item that does not fit is left out with every item after it. Each item is a generated row (hints > row, gap: 1 by default) holding a .hint-key and a .hint-label text.

    <bind keys="space" action="check-toggle" when="#procs:focus" label="mark"/>
    <bind keys="ctrl+a" action="check-all" keycap="^A" label="all"/>
    …
    <hints id="keys"/>
    
  • layout: grid (on screen, box, col, row, scroll, item, tab, modal) places the in-flow children in equal columns, row by row. grid-columns: K is the column count (1–12); with grid-min-width: M the count adapts to the width, clamp(floor((W + gap) / (M + gap)), 1, K), so a responsive grid needs no @media. Docked children are pulled out first (in a scroll too, where they scroll with its content), and W is the width they leave. Without grid-min-width, an auto-width grid is as wide as K columns of its widest child. gap separates columns and rows; a row is as tall as its tallest child, and align places a shorter one. A child's width, min-width, max-width, flex, and an fr/% height are ignored (L007, a warning, when the document wrote them). A grid box is never shrinkable; put it in a <scroll> (or make it a scroll/overflow: scroll grid) to fit it to the space left and scroll its rows.

    #cores { layout: grid; grid-columns: 4; grid-min-width: 22; gap: 1; }
    
  • each on col, row, and box. Every child element is the template, inflated once per array element, in array order, with the alias in scope; the container itself is not repeated, and its own if, hidden, disabled, title, and guards use the enclosing scope. Each generated top-level node dumps the element's key (from key="path", else the index). A missing or non-array path is B001; an empty array leaves the container empty, so it matches :empty. The template holds nothing that takes focus or keeps state: input, button, list, modal, table, tabs, and tab are V001, focusable, on:click, and on:focus V002, any id V004; an alias equal to an enclosing one is V011.

    <row id="tags" each="tags as t" key="t"><text class="tag">{t}</text></row>
    
  • Multi-select. checked="path" on a list or a table (which needs each and key, V018 otherwise) holds the keys of the checked rows: an array in the store that the runtime reads and writes. A missing path counts as [] (B003); any other value is B008, shows nothing checked, and is never overwritten: the check-* rows skip that widget. They skip it too when the path is missing under a value that is not an object (checked="sel.marked" with sel a number), which the runtime could not write. A list's rows are the elements of its array, even when if or hidden removes their item: the cursor can rest on such a row and check-toggle/check-all include it, so filter the array in the host rather than hide items. mark="✓" (1 or 2 columns, and only with checked) reserves width(mark) + 1 columns at the left of every row, checked or not, and paints the mark on the checked ones. on:change on a list (only with checked) fires with the new array as its value when check-toggle, check-all, or check-none changed it. Checked rows dump "checked": true, and their text node line ends with checked. No key is implicit: bind space, ctrl+a, … yourself.

  • Built-in actions (keymap rows only; V003 in on:*): move-next, move-prev, move-first, move-last, move-page-down, and move-page-up move a list's or a table's cursor (writing its bind, firing on:select when it moved) or a viewport's offset, and move-next/move-prev (wrapping) and move-first/move-last move a tabs' active tab over its enabled visible tabs; check-toggle, check-all, and check-none change a list's or a table's checked array; switch-to (which needs to=) activates a tab (a visible, enabled one whose tabs is in the frame) or switches screens. The target is the node to= names, else the focused node. A row whose target is missing from the frame, disabled, or incompatible (an empty list or table, one without checked, a button, move-page-* on a tabs) does not match, and the key goes on to the next rows, so <bind keys="ctrl+a" action="check-all"/> never steals ctrl+a from a widget without checked; a matching row takes its key even when nothing changes. B007 (an error, reported without data) flags a target known to be incompatible from to= or from a when that is exactly #id:focus. The built-ins are never B004, and Catalog() lists them with Builtin: true; play records the events they fire, not the actions.

  • when over the focus chain. In a version="2" keymap, when matches when its selector matches the focused node or any ancestor up to the screen (a modal's parent is its screen), or, with nothing focused, the top open modal or the screen. when="#pane" holds while focus is anywhere inside #pane. The event of a host action still names the focused node as its source. version="1" keeps matching the focused node only.

  • One key dispatch serves Run(), play, and <hints>: esc fires the top modal's on:escape (or, in a <tui> without version, closes a modal that has none: see the key rules in the Language notes); then the focused, enabled widget consumes its own keys (an input its typing and editing keys, a list or a table with rows up, down, home, end, pgup, pgdn, a focusable tabs with an enabled tab left and right, another viewport the arrows and paging keys, any other node with on:click except a list or a table enter and space); then the first keymap row whose keys, when, and built-in target match; then tab, shift+tab, and ctrl+c.

  • The mouse. <tui version="2" mouse="mouse_enabled"> turns mouse input on while its flag (true, false, a path, or !path; default false, so the terminal keeps its native text selection) is truthy. It is evaluated on every frame: a host or a Settings screen switches it with Set, and Run() writes CSI ?1000h CSI ?1006h (SGR reports) or CSI ?1000l CSI ?1006l before the next frame, and turns it off on the way out. A missing path is B002 and counts as null, so mouse="path" is off and mouse="!path" is on. Only the left button and the wheel act, on the frame on the screen; while a modal is open only events inside the top modal count, and there only the modal and its nodes receive them, even when it paints nothing (visibility: hidden), so the screen nodes that show through never do. A click acts on the release, when press and release land on the same node: the same layer and path of child indices, where a list or table row counts as its row (its index in the array and its key). So two rows sharing a template id differ, and when a table scrolls or the host reorders the array between press and release, so that another row lies under the pointer, nothing happens. Walking up from the node under the pointer, the first rule that applies decides: a disabled or visibility: hidden node stops the walk; a tab label activates its tab; a list or table row focuses its widget (on:focus) and moves the cursor there (on:select); a column with on:click fires it without moving focus; a node with on:click takes focus if it can and fires it; any other focusable node takes focus. The wheel moves a list or table cursor by one (on:select, focus stays), a tab strip by one tab (wrapping), or another viewport by one row, with or without an id. The hit test is the one tuimark inspect --at X,Y uses.

version="3": clipping warnings (0.3)

A version="3" document is a version="2" document with two extra layout warnings. They name content that a dump shows as gone but that no error mentioned:

  • L008: a text loses content without saying so. Either a nowrap line is wider than its box, with no ellipsis, or its lines outnumber its box's rows.
  • L009: a node cuts a child on an axis it does not scroll, for example a fixed-height grid whose second row does not fit.

Both show up in validate (at 40, 80, and 120 columns) and in a dump's errors. The dump also marks the node with "clipped": "text" or "children", and the text dump ends its line with clipped. When a cut is what you want, say so in the stylesheet and the warning goes away: wrap: nowrap on the text or an ancestor for a wide line, and overflow: hidden on the node that cuts. Nothing else changes: for a document that clips nothing, version="3" dumps are byte-identical to version="2" ones. A document without version gets them too. The warnings stay out of version="1" and version="2" documents, so existing hosts whose tests expect an empty Validate() keep passing.

CLI reference

tuimark dump     FILE [--cols 80] [--rows 24] [--format text|json] [--data FILE.json]
                      [--cells] [--styles] [--strict] [--theme dark|light]
tuimark validate FILE [--json] [--strict] [--catalog FILE] [--data FILE.json] [--theme dark|light]
tuimark preview  FILE [--cols 80] [--rows 24] [--data FILE.json] [--watch] [--theme dark|light]
                      [--color truecolor|256|16|none]
tuimark play     FILE [--cols 80] [--rows 24] [--data FILE.json] [--theme dark|light]
                      [--input STEPS | --script FILE.ndjson]
                      [--format text|json] [--cells] [--styles] [--frames] [--strict]
tuimark fmt      FILE [--write] [--check]
tuimark ir       FILE
tuimark agents   [--repo | --project]
tuimark catalog
tuimark new      NAME [--go | --ts]
tuimark test     [DIR] [--update] [--allow-breaking]
tuimark inspect  FILE (--at X,Y | --id ID) [--cols 80] [--rows 24] [--data FILE.json]
                      [--theme dark|light] [--strict] [--json]
tuimark run      FILE [--data FILE.json] [--theme dark|light] [--events FILE.ndjson]
                      [--mock FILE.json] [--strip]
tuimark host     FILE [--data FILE.json] [--theme dark|light] [--reply-timeout 5s]
tuimark mcp
tuimark lsp
tuimark version
  • dump renders one frame: the text dump (grid + node geometry + diagnostics) by default, or --format json for the machine-readable shape agents parse (SPEC §13.2). --data FILE.json binds the file as the JSON store first. --cells (JSON only) adds per-cell {x,y,ch,id} ownership; --strict upgrades missing bind paths (B003) to errors; --theme/--styles are below. Exits 2 when the dump has an error-severity diagnostic.
  • validate prints diagnostics (or, with --json, the full shape including the action catalog) without laying out a frame for its own sake — it still renders internally at 40/80/120×24 to catch layout/bind problems (under both themes, dark first, for a theme="auto" document without --theme). Without --data, bind-dependent checks (B001–B003, the 0.2b B008–B010, and the 0.4b B011 and B012) are skipped, since every path would otherwise read as "missing", and the text output ends with ok (bindings not checked: no --data) instead of ok (0.4a); with --data the line stays ok. --json has the members actions, data (the --data argument as given, or null), diagnostics, file, and ok. --catalog FILE (a JSON list of action names, or {"actions":[...]}) turns unresolved actions into B004 warnings.
  • preview writes the text dump to stdout, and also paints an ANSI frame first when stdout is a TTY, in the color profile from --color, else TUIMARK_COLOR, else detection (the same order and detection rule Run() uses; see "Environment variables" below) — resolved once per preview, including across --watch reloads. --watch re-renders whenever the document, its stylesheets, or --data change (polled every 150ms).
  • play replays input against a document with no TTY and prints the resulting dump plus the actions it fired (below).
  • fmt is the canonical formatter (below).
  • ir prints the source IR as JSON (SPEC §13.1): a document's parsed shape from before the stylesheet cascade (SPEC §18's "parse .tui → source IR" step, ahead of "apply stylesheet + media") — a node's style map holds only its own presentation attributes and style="", not rules matched from a <style> sheet. It exits like every other command (2 on any diagnostic with error severity, including stylesheet-only errors). A version="1" document (and the spike) gives "version": "0.1", valid against schema/ir.v0.1.json; a version="2" document gives "version": "0.2", valid against schema/ir.v0.2.json, which adds the six new kinds, app.mouse, and the keymap's label/keycap (class:NAME attributes stay in attrs under their full names). Its tokens are resolved for the document's theme with auto as dark; a :root rule inside @media (theme: …) counts only for that theme.
  • inspect explains one node of one frame (below).
  • agents prints AGENTS.md, generated from the runtime's own catalogs so it cannot drift; AGENTS.md at the repo root must equal this output, enforced by internal/agentsdoc's TestAGENTSDoesNotDrift (regenerate with go run ./cmd/tuimark agents --repo > AGENTS.md). agents --project (0.4a) prints the project briefing instead: the same briefing adapted to a project outside this repository, which new writes as the project's AGENTS.md (so it can be regenerated after an upgrade). --repo and --project are exclusive.
  • catalog (0.4a) prints the closed vocabulary as one JSON object, so an editor, a language client, or an agent reads the catalog instead of scraping AGENTS.md: tags (with their attrs and an enumerated attribute's keywords), properties, pseudo_classes, media, keys, actions, and diagnostics, every item with the lowest document version that accepts it (since). It is generated from the runtime's own tables, takes no flags, and reads no environment variable. schema/catalog.v1.json validates it; catalog: 1 is the format version, and adding members, items, or versions keeps it.
  • new NAME [--go | --ts] (0.4a) creates a project in the directory NAME, which must not exist or must be empty (otherwise it is a usage error that writes nothing, and no file is ever overwritten): app.tui (the newest document version, with a bound list, a status line, a keymap with labeled rows, and <hints/>), theme.tcss (tokens for both themes, :focus and :selected rules), sample.json, and the project briefing as AGENTS.md. --go adds main.go and a go.mod that requires this module at the version tuimark version reports; --ts adds parent.ts and package.json (a Bun parent through @thelacanians/tuimark). The two flags are exclusive. It prints the files it wrote, then the next command (cd NAME && tuimark run app.tui --data sample.json, … go mod tidy && go run ., or … bun install && bun parent.ts).
  • test runs the golden dump comparisons driven by testdata/golden/manifest.json (below).
  • run (0.4a) runs a document in the terminal for its author, with no Go and no parent program: the session is Run()'s (it reads the TUIMARK_* variables as Run() does, so TUIMARK_THEME beats --theme), --data is bound at the root, and --theme is applied as Set("@theme", …). Every non-builtin action gets a handler that applies its --mock writes ({"ACTION": [{"path": P, "value": V}, …]}, literal values, one Batch) and records the event, in an event strip that F12 (or --strip) shows over the bottom rows and in the --events NDJSON file ({"seq","action","source","keys","value"}, mode 0600, no value and "redacted": true for a secret input). A panel over the top rows lists the static diagnostics, or a failed reload's error. The document, its stylesheets, --data, and --mock are polled every 150ms and reloaded in place, keeping the store (unless --data changed) and, by id and tag, the focus, the screen, cursors, scroll offsets, input text, and the active tab; a failed reload keeps the old document running. Two ctrl+c within a second always exit 0. Exit codes: 0 for quit or the end of input, 1 for an error (a bad --mock, an --events path that is a terminal, … are one tuimark: run: … line before the terminal is touched), 128 + the signal number for a stop signal. See the run guide.
  • host (0.3b) runs a document in the terminal for a parent process in any language: the terminal stays on fd 0/1/2 as with Run(), the parent writes set/bind/batch/get/reply JSON lines to fd 3 and reads ready/event/ack/error/exit JSON lines from fd 4. POSIX only (a usage error on Windows). A line of 64 MiB or more on fd 3 (the newline excluded) ends the session with exit reason error (0.4a), the bound mcp uses for stdin. See the host protocol guide and examples/host-ts for a Bun parent.
  • mcp (0.3b) is a Model Context Protocol server on stdin/stdout (JSON-RPC, stdlib only) exposing validate, dump, play, inspect, and the agents briefing as five read-only tools, for an agent that reaches Tuimark through an MCP gateway instead of a shell. A line of 64 MiB or more on stdin (the newline excluded) ends the server with exit 1. See the MCP server guide.
  • lsp (0.4a) is a Language Server Protocol 3.17 server on stdin/stdout (stdlib only) for .tui and .tcss files: diagnostics (the checks validate runs, against a sibling sample.json), completion, hover, definition, and formatting for .tui, from the catalog. The VS Code extension starts it. See Editor support.
  • version (also --version or -v) prints tuimark 0.2.0; release builds add the commit and build date, as in tuimark 0.2.0 (commit abc1234, built 2026-09-27T12:00:00Z).
--theme and dump --styles

--theme dark|light (on dump, validate, preview, play, and inspect) overrides the document's own theme attribute for that one render — the same override Run()'s TUIMARK_THEME environment variable applies for a live session (SPEC §26.4). Any other value the flag is explicitly given — including auto (no tool probes the terminal) and "" — is a usage error that exits 1 before anything is rendered; only leaving --theme off entirely keeps the document's own theme, and a document's theme="auto" is dark in every tool. The golden manifest's theme field (below) follows the same rule.

The host's theme, Set("@theme", "dark"|"light"|"auto") (a reserved path, in both document versions), beats both the document's theme and --theme; in Run(), TUIMARK_THEME beats it, and auto resolves from the terminal's background. Whatever Run() probed never reaches Dump() or Validate().

Environment variables (Run() and preview --color)

Run() reads three environment variables before it touches the terminal (SPEC §26.10); an invalid value is a usage error and Run returns before entering raw mode. Values are exact and case-sensitive; an empty value counts as unset.

Variable Values Meaning
TUIMARK_COLOR truecolor, 256, 16, none forces the color profile (SPEC §26.3), instead of detecting it from TERM/COLORTERM/NO_COLOR/WT_SESSION
TUIMARK_THEME dark, light overrides the document's theme, same as --theme (SPEC §26.4)
TUIMARK_SYNC 0, 1 forces synchronized-output framing off or on, instead of probing the terminal (SPEC §26.6)
TUIMARK_LOG a file path writes an NDJSON log of the session (below); a path that cannot be opened, or that names a terminal, makes Run return an error before touching the terminal
COLORFGBG set by some terminals the fallback for theme="auto" when the terminal does not answer the background query: its last ; field, 0–6 and 8 dark, 7 and 9–15 light

TUIMARK_LOG=FILE (SPEC §26.12) makes Run() write one JSON object per line to FILE — created with mode 0600, or, when it is an existing regular file (or a symlink to one), truncated and narrowed to 0600 — so diagnostics never land on the terminal Run() owns. Anything else that is not a terminal (/dev/null, a FIFO, a shell's >(jq …) pipe) is written as it is, never truncated and never chmod-ed; a terminal is refused with an error. A Run() that fails before its session starts (stdin is not a terminal) leaves the file with no records. Each record starts with ev and t (milliseconds since Run() started): start (version and size), caps (whether the probe ran, the color profile, synchronized output, grapheme mode, and the effective theme with where it came from), then one record per key (by token), paste (its normalized length), mouse (kind press, release, wheel-up, or wheel-down, and the 0-based cell; only while the mouse is on), mode (each mouse mode change written), action (the §8.2 payload), frame (microseconds and bytes written), and resize, and finally end with its reason (quit, eof, signal, or error). While an enabled secret input has focus, key and paste records are just {"ev", "t", "redacted": true}, and that input's events are logged without their value. A write error stops the logging, never the app.

preview also reads TUIMARK_COLOR — but only when stdout is a TTY (so a plain, redirected preview never depends on the environment), and only as a fallback: --color on the command line wins first. An invalid TUIMARK_COLOR makes preview exit 1 before writing anything, the same way it makes Run() return an error. dump, validate, play, ir, fmt, test, Dump(), and Validate() never read any TUIMARK_* variable, so their output never depends on the environment.

dump --styles (also play --styles; JSON output only) adds two fields to the dump: "theme", the effective theme, and "styles", one array of spans per row. Each span ({"x", "w", "fg", "bg", "a"}) is a maximal run of adjacent columns sharing a foreground, background, and attribute set; every row's spans are contiguous and cover every column. Colors are canonical — "#rrggbb" (lowercase), an ANSI name such as "bright-cyan", or "default" for an unset color — and are never downsampled (downsampling is only a Run()/preview terminal-output concern). "a" lists whichever of bold, dim, italic, underline, reverse are on, in that order, and is omitted when none are. This is how a test asserts :focus, :selected, and reverse-selection styling by diffing JSON instead of reading preview's ANSI output by eye.

tuimark play
tuimark play app.tui --data sample.json \
  --input "text:log tab down" --format json

play loads a fresh app from FILE (--data binds the store root, as for dump) and replays a session against it with no TTY: the same primitives Run()'s event loop uses (key/paste decoding and coalescing, event dispatch, the lifecycle-event settle loop), just never touching a real terminal. Built-in actions (quit, focus) run; a host action registered with On does not exist here, so it is only ever recorded, never executed — play checks what the document would dispatch, not a Go handler's own logic (simulate a handler's effect with a set: step, or test the Go side with go test or Glyphrun).

Steps come from --input "STEP STEP ..." (space-separated; a run of spaces is one separator) or --script FILE.ndjson (one JSON object per line, for steps whose payload needs an embedded space or explicit JSON), never both. With neither, the session is just the first frame (step 0).

--input step Meaning
KEY one key: a named key (enter esc tab backspace space up down left right home end pgup pgdn shift+tab), ctrl+a..ctrl+z, or any other single printable ASCII character but , (write /, not slash — there are no aliases)
text:STR STR's bytes, decoded like real terminal input: a run of printable characters going to a focused input is one edit and one on:change (the same coalescing Run() does for fast typing or an unbracketed paste); characters that go elsewhere reach the keymap one by one. In a version="2" document each key or paste of the step sees the frame the previous one left, as in Run(), so text:ab\r and text:ab enter fire the same events
paste:STR one bracketed paste. Normalized (CRLF/CR/LF/TAB → space, ANSI/control bytes stripped) and delivered as one edit/on:change when an enabled input has focus; discarded — never reaching the keymap or a built-in — otherwise, so a paste containing q can never quit
set:PATH=JSON Set(PATH, json.Unmarshal(JSON)); PATH may be empty (the store root) or the reserved @focus/@screen/@theme (set:@theme="light")
focus:#ID Set("@focus", "ID") (the # is optional); fires no on:focus, like Set
resize:COLSxROWS the terminal size changes, handled like Run() handles SIGWINCH
click:X,Y, wheel-up:X,Y, wheel-down:X,Y a left click (press and release) or one wheel report at cell (X, Y), 0-based as in the dump, on the current frame (version="2" mouse, SPEC §8.5). With the document's mouse false the step changes nothing and fires nothing; a cell outside the current grid is a usage error

The equivalent --script line for each: {"key":"..."}, {"text":"..."}, {"paste":"..."}, {"set":{"path":"...","value":...}}, {"focus":"#..."}, {"resize":[COLS,ROWS]}. {"theme":"light"} ("dark", "light", or "auto") is Set("@theme", value), like set:@theme="light"; since play never probes the terminal, auto is dark. {"click":[X,Y]} is click:X,Y, and {"wheel":"up","at":[X,Y]} (or "down") is wheel-up:X,Y/wheel-down:X,Y — the one step object with two members, exactly those two.

A quit (a keymap quit, or ctrl+c with nothing else claiming it) ends the session early: later steps are never applied. --format json prints the final dump (with --cells/--styles as for dump) plus "events" — every dispatched action, in order, each {"step", "action", "source", "keys", "value"} — always present, [] when nothing fired. --frames adds "frames", one {"step", "input", "events", "dump"} per applied step (step 0 included). --format text appends an === events === section (STEP ACTION SOURCE KEYS VALUE, or the single line none) after the text dump, with one such section per step when --frames is given.

Exit codes: 0 the final dump is ok; 2 it has an error-severity diagnostic; 1 a usage or I/O error (an unparseable step, a mouse step outside the grid, a Set error such as a bad path or an unknown screen) — nothing is printed to stdout in that case, only tuimark: play: step N (INPUT): REASON to stderr.

Exit codes, uniform across every command: 0 ok, 1 usage, I/O, or crash, 2 validation errors (or, for fmt --check / test, "not formatted" / "golden mismatch"). On the CLI, --cols/--rows must be between 1 and 1000 (the underlying Go Dump itself accepts 0, giving an empty grid).

tuimark fmt

Canonical formatting, built on the lossless XML tree so it survives comments and works on any well-formed document regardless of catalog or binding errors (only not-well-formed XML, V005, is refused):

  • 2-space indentation, one element per line.
  • Attributes reordered to id, class, the class:NAME guards in source order, then the rest in source order.
  • Elements with no children and no text self-close (<box id="a"/>).
  • <text>/<button> bodies (and a version="2" <column>'s cell template) that fit on one line stay inline (<text>hi</text>); multi-line bodies are indented one level, one output line per text line.
  • Inline <style> bodies are re-indented one level and re-escaped (&, <, ]]>; CDATA is not preserved); relative indentation within the body is kept.
  • Comments are preserved in place.
  • &lt; &gt; &amp; are escaped in text, &quot; &amp; &lt; in attribute values.
  • Output always ends with exactly one newline.

Default prints the formatted document to stdout; --write rewrites the file in place; --check prints the file name and exits 2 if it is not already formatted (no write). Both examples/spike/inbox.tui and examples/inbox/app.tui already pass fmt --check.

tuimark inspect
tuimark inspect app.tui --data sample.json --at 12,3 --json
tuimark inspect app.tui --data sample.json --id inbox

inspect renders one frame exactly as dump does with the same flags (it is deterministic, like dump) and explains one node of it: "why does this cell look like this?" --at X,Y (0-based, as in the dump) picks the node the cell belongs to — the node whose id owns the cell in the --cells map, then down through its anonymous children that contain the cell (a list row, a text); --id ID picks the first laid-out node with that id. Exactly one of the two is required.

--json prints cols, rows, ok, the node (as in the dump), its layout path (/screen#main/col#app/list#inbox/item[3]/text), the cell (with --at), the pseudo-classes that match it, one entry per class it could have ({"name", "from", "guard", "active", "used"}: from is class or class:NAME; used tells whether any selector of the loaded stylesheets or of the built-in sheet names it), and one style entry per TCSS property, in a fixed order: its computed value, its origin (ua, attribute, author, inline, inherited, or initial), the winning rule (file, line, column, selector as written — the whole list for a, b, with the specificity of its highest matching selector — or the attribute as written, or style="" — specificity, and @media condition), and the declarations it overridden, highest priority first (a declaration of a selector list counts once, never against itself). The text form (the default) shows the same, one line per property that is not initial.

A cell outside the grid, an id no laid-out node has, or a malformed value exits 1 with nothing on stdout; a frame with an error-severity diagnostic exits 2 and still prints the report.

tuimark test and the golden manifest

testdata/golden/manifest.json is a JSON array:

[
  {
    "name": "spike",
    "file": "examples/spike/inbox.tui",
    "sizes": ["40x24", "80x24", "120x24"],
    "json": ["80x24"],
    "frozen": true
  }
]

data is optional. For every size in sizes, tuimark test compares the text dump against DIR/<name>/<size>.txt; for sizes also listed in json, it compares the JSON dump against DIR/<name>/<size>.json. DIR defaults to testdata/golden. One PASS/FAIL line is printed per golden, with a short diff hint on failure; the command exits 2 on any mismatch, 1 on an I/O error (a document or golden file that can't be read). --update (re)writes the goldens for every entry whose frozen is not true — spike's goldens are frozen and must never be regenerated; inbox, stacked, unicode, and the SPEC literal fixtures under specs/fixtures/ are not: table (§6.9.5, at 30x5), tabs (§6.10.5, the strip at 8, 12, 20, 30, and 40 columns), hints and hints-filter (§6.12, the second after the play step focus:#filter), and grid (§11.7, at 20 and 30 columns). Adding another fixture just needs another row in the manifest and a golden directory; nothing else in the runner is fixture-specific.

An entry also takes these optional fields:

Field Meaning
theme "dark" or "light", as --theme
input play steps (--input's grammar, above); the goldens for this entry are then play output (an === events ===/"events" section too) instead of plain dump output
script a play script path (--script's grammar), exclusive with input
styles true to have the JSON goldens include "theme" and "styles"
cells true to have the JSON goldens include "cells" (0.2b)

--update's superset check. For a non-frozen entry and a size where a JSON golden already exists, --update refuses to overwrite it unless the newly rendered JSON is a superset of the one on disk: every scalar that was there keeps its exact value, every array keeps its exact length (element by element a superset too), and an object may only gain new members. When it is not — a value changed, an array grew or shrank, a member disappeared — neither the .json nor the paired .txt golden is written for that size; tuimark test instead prints FAIL NAME SIZE (json): update removes or changes POINTER (an RFC 6901 JSON Pointer to the first offending location) and exits 2. This catches an accidental regression at update time instead of only at the next git diff. --allow-breaking skips the check for one run, for a reviewed, deliberate change (a moved node from an auto-fit change, for example) — say why in the commit that updates the goldens.

Public Go API

The public surface is deliberately small (package tuimark, SPEC §18): eighteen entry points.

func Load(path string) (*App, error)
func LoadFS(fsys fs.FS, name string) (*App, error)            // 0.3
func Parse(r io.Reader) (*App, error)

func (a *App) Bind(path string, v any) error
func (a *App) Set(path string, v any) error
func (a *App) Get(path string) (any, bool)                     // 0.3
func (a *App) Batch(fn func(b *Batch) error) error              // 0.3
func (a *App) On(action string, h Handler)
func (a *App) Catalog() []ActionSpec
func (a *App) Dump(cols, rows int) (*Dump, error)
func (a *App) Validate() []Diagnostic
func (a *App) Play(opts PlayOptions, steps ...string) (*PlayResult, error) // 0.3
func (a *App) Run(w io.Writer) error
func (a *App) RunContext(ctx context.Context, w io.Writer) error                // 0.4
func (a *App) RunWith(ctx context.Context, w io.Writer, opts RunOptions) error  // 0.4
func (a *App) Quit()                                                            // 0.4
func (a *App) Every(d time.Duration, fn func(b *Batch) error) (stop func())    // 0.4
func (a *App) Derive(path string, fn func(get func(path string) (any, bool)) (any, error)) error // 0.4

type RunOptions struct {                                        // 0.4
    OnError func(ev *Event, err error) error
}

internal/layout and internal/paint are not importable from outside this module; the only supported entry points are the functions above.

  • Load/Parse never fail on diagnostics in the document — a bad .tui loads fine; read Validate() or Dump() to see the problems. Only I/O (Load) or a read error (Parse) returns an error here.
  • Bind/Set coerce v to JSON-shaped data (null | bool | number | string | array | object) at the boundary; NaN/±Inf are rejected, as are values with no JSON shape (channels, funcs). Set additionally schedules a redraw and is safe from any goroutine. Three paths are reserved for hosts, not markup: Set("@focus", "#id") moves focus, Set("@screen", "id") switches the active screen — the SPEC says only "the host switches by id" without naming an API, and these paths are how this runtime exposes it without growing the public surface — and Set("@theme", "dark"|"light"|"auto") sets the host's theme (any other value is an error and changes nothing), for Run() and for Dump()/Validate() alike.
  • Event is the payload a handler receives: Action (the name), Source (the firing node's id), Keys (a snapshot of the selected item's each aliases, e.g. {"item": "t-12"}), and Value (the input's text for events from a focused input, such as on:change/on:submit or a keymap row; the selected item's id or index for a static, non-each list; the new checked array for a list's on:change; nil otherwise).
  • ErrQuit, returned by a handler, stops Run cleanly.
  • Dump is a side-effect-free snapshot: it never moves focus, queues events, or mutates scroll/list/input state, so dumping the same app at several sizes never depends on call order. A negative cols/rows is an error; 0 gives an empty grid. Validate has the same no-side-effects property.
  • Run drives the app in the terminal (raw mode) until quit. SIGTERM, SIGHUP, and SIGINT also stop it (Unix; on other platforms only os.Interrupt): the terminal is restored first (cooked mode, main screen, visible cursor), and Run returns a non-nil error naming the signal, so the process can exit with a failure status instead of exiting clean — even when a handler had already returned ErrQuit as the signal arrived.
  • LoadFS (0.3) loads a document from an fs.FS, so a view embedded with go:embed loads without being copied to disk. A relative <style src> resolves inside the file system, next to the document. A src that climbs out of it is a V006 diagnostic, not an error.
  • Get (0.3) returns a copy of a store value, with the same path grammar as Set. @focus, @screen, and @theme return the app's current focus, screen, and theme.
  • Batch (0.3) applies several Sets as one change. No frame, dump, or handler sees some of them without the others, and the batch asks for one redraw. If any write fails, none is applied. It is the way to publish a sample of live data.
  • Play (0.3) is Run without a terminal, for tests. It replays the steps of tuimark play ("7", "text:hello world", "click:12,3", …) against the app itself, calls the registered handlers, and returns the events and the final dump. The state carries over to the next call. With NoHandlers on a freshly loaded app, it gives exactly what tuimark play prints.
  • RunContext/RunWith (0.4) are Run with a context: a cancelled context ends the session as a quit does (the terminal is restored) and RunWith returns an error wrapping ctx.Err(). RunOptions.OnError receives each handler, Every, or Derive error that is not a quit (ev is nil for the last two); returning nil keeps the session going.
  • Quit (0.4) asks the active Run to end, from any goroutine; Run then returns nil. It does nothing while no Run is active.
  • Every (0.4) calls a function every d while Run is active, on the loop, its writes applied as one Batch. Missed ticks are dropped, as with time.Ticker; Play, Dump, and Validate never call it.
  • Derive (0.4) keeps a store path computed from the rest of the store (a filtered list, a count), recomputed inside every write, so markup reads it as any other path and stays free of expressions. Only its function writes it: a Set to it is an error, and a widget bound to it does not change.

Examples

Every example below is pictured on tuimark.dev/examples, rendered from tuimark play (or, for the ones that move on their own, from the app's -demo mode).

  • examples/spike/inbox.tui — the phase-0 fixture (5 tags: app col row box text). Its goldens in testdata/golden/spike/ are frozen: they are never regenerated, by design — a geometry mismatch there is a runtime regression, not a fixture update.

    ./bin/tuimark dump examples/spike/inbox.tui --cols 80 --rows 24
    
  • examples/inbox/{app.tui,theme.tcss,sample.json} — the SPEC §17 fixture (keymap, focus, input, list, binding, each/if, scroll, spacer, media queries; see examples/dashboard below for button/modal/progress/ rule). These three files are kept byte-identical to the SPEC's fixture text; examples/inbox/main.go is an interactive host wiring open/search/quit.

    go build -o bin/inbox ./examples/inbox
    ./bin/inbox
    
  • examples/dashboard — a multi-screen host (app.tui, theme.tcss, sample.json, actions.json) exercising screen switching and a confirm-style modal; driven by specs/glyphrun/dashboard_*.yml.

    go build -o bin/dashboard ./examples/dashboard
    ./bin/dashboard
    ./bin/dashboard -dump 80x24   # print the JSON dump instead of running
    
  • examples/agent — an interactive host around a scripted or Ollama-backed chat provider (agent.tui, engine.go, provider.go, tools.go, its own README.md); driven by specs/glyphrun/agent_*.yml and has its own Go tests (go test ./examples/agent). The whole view is .tui/.tcss; Go only supplies data and named actions through the public API above.

    go build -o bin/tuimark ./cmd/tuimark
    go build -o bin/agent ./examples/agent
    
    # scripted provider (default), interactive, seeds a demo workspace
    # (--demo-workspace deletes and recreates the given directory)
    ./bin/agent --demo-workspace ./demo-workspace
    
    # headless, machine-readable, one shot
    ./bin/agent --headless --json --demo-workspace ./demo-workspace -p "list"
    
    # headless, then print the resulting UI's own JSON dump instead
    ./bin/agent --headless --dump 120x30 --demo-workspace ./demo-workspace -p "list"
    
    # the in-process loopback fixture: exercises the real HTTP streaming
    # client end to end with no model
    ./bin/agent --fixture --demo-workspace ./demo-workspace
    
    # a real local Ollama model
    ./bin/agent --provider ollama --model qwen3.5:0.8b --demo-workspace ./demo-workspace
    

    See examples/agent/README.md for the tool sandbox, the two providers, and the keys and main flags (./bin/agent --help lists all flags).

  • examples/monitor — the 0.2b acceptance fixture (SPEC §17.4): monitor studio's Overview (a panel grid with sparklines and eighths gauges), CPU (a core grid in a scroll with a scrollbar), Processes (a table whose columns follow the width by @media, multi-select with ▸, a filter input, the kill confirmation, and the process detail), and Settings, under a nine-label tab strip and a contextual <hints> bar, in a version="2" document with theme="auto" and the mouse on. studio.tui and studio.tcss are the SPEC's literal text; sample.json pins the rows the goldens assert; main.go is a host with no layout, width, padding, truncation, or column code — a seeded fake collector and the named actions (sorting with the arrow as data, the filter, kill and force kill, the pinned detail, settings that switch the mouse and the theme) through the public API only. Its goldens are the monitor-* entries of testdata/golden/manifest.json, and specs/glyphrun/monitor_*.yml drive the built binary.

    go build -o bin/monitor ./examples/monitor
    ./bin/monitor
    ./bin/monitor --theme light        # Set("@theme", "light") before Run
    ./bin/monitor --dump 120x30        # print Dump() as JSON instead of running
    ./bin/tuimark play examples/monitor/studio.tui --data examples/monitor/sample.json \
      --cols 120 --input "tab tab 9 tab click:70,1"
    
  • examples/logs, examples/tasks, examples/gitui, examples/http — four complete apps in version="3" documents, each a .tui, a .tcss, sample.json, a mock.json for tuimark run --mock, and a Go host on the 0.4 API (Every, Derive, RunWith) with its own Play tests and README. logs is a live log viewer (<scroll stick="bottom">, a filter, level toggles; -follow FILE tails a real file); tasks a kanban board (multi-select, form and confirm modals; -file persists it); gitui a git client (status with a diff preview, the log, branches; -repo PATH reads a real repository, read-only without -write); http an HTTP API client (a collection, an editor, the response; an in-process fake API unless -base URL). examples/README.md lists every example.

    go build -o bin/logs ./examples/logs && ./bin/logs
    go build -o bin/tasks ./examples/tasks && ./bin/tasks
    go build -o bin/gitui ./examples/gitui && ./bin/gitui -repo .
    go build -o bin/http ./examples/http && ./bin/http
    ./bin/tuimark run examples/tasks/app.tui --data examples/tasks/sample.json \
      --mock examples/tasks/mock.json   # the view with no Go
    
  • examples/snake, examples/pipeline, examples/music, examples/pomodoro — four apps that move on their own, built the same way (a version="3" document, a .tcss with both palettes, sample data, a mock.json, a Go host with its own tests). snake is a playable game whose Every tick re-arms faster as the level rises (-autopilot plays it for you); pipeline a CI run with parallel jobs, spinners, a streaming log, and a retry; music a player with a spectrum visualizer (no audio); pomodoro a timer and a clock in big block digits. Each has a -demo COLSxROWS flag that prints a scripted session's frames as JSON lines; the docs site animates them.

    go build -o bin/snake ./examples/snake && ./bin/snake -autopilot
    go build -o bin/pipeline ./examples/pipeline && ./bin/pipeline
    go build -o bin/music ./examples/music && ./bin/music
    go build -o bin/pomodoro ./examples/pomodoro && ./bin/pomodoro -start
    
  • examples/chess, examples/tetris, examples/minesweeper, examples/2048, examples/sudoku — five more games, built the same way and with the same -demo flag. chess plays against an alpha-beta engine (six levels), a second player, or itself, with clocks, undo, hints, SAN or UCI move entry, PGN and FEN import and export, and move generation checked by perft; tetris has SRS rotation, hold, a ghost piece, and an autopilot; minesweeper has three boards plus custom sizes, chording, and flags, by mouse or keyboard; 2048 slides tiles on a 3×3 to 6×6 board with undo and an autopilot; sudoku generates puzzles with a single solution, graded by the techniques they need, with notes, hints, and a clickable number pad.

    go build -o bin/chess ./examples/chess && ./bin/chess
    go build -o bin/tetris ./examples/tetris && ./bin/tetris
    go build -o bin/minesweeper ./examples/minesweeper && ./bin/minesweeper
    go build -o bin/2048 ./examples/2048 && ./bin/2048
    go build -o bin/sudoku ./examples/sudoku && ./bin/sudoku
    
  • kit/ and examples/kit-gallery — a copy-paste component kit in the spirit of shadcn (design tokens, k- component classes, and 21 small documents, one per component, under kit/components/) and an app that shows every component on five tabs. You copy what you need into your project and own it; the kit adds no tag, attribute, or property. Its host helpers do what markup cannot (toasts that expire, filter, sort and pages, form validation, dialog routing, the palette's commands, async loads, formatting), as files to copy too: kit/go (a nested module, not for go get) and kit/ts (typed against @thelacanians/tuimark). See kit/README.md, the kit guide, and every component pictured on tuimark.dev/kit.

    go run ./examples/kit-gallery
    tuimark run kit/components/table.tui --data kit/components/table.sample.json
    
  • examples/containers — a container-engine dashboard built only from the kit, over a simulated engine (a seeded state machine; no Docker): a filterable, sortable, multi-select table whose columns hide by priority, a detail pane with CPU and memory sparklines and a log that follows its end, confirm dialogs, a command palette, a help overlay, toasts, and an animated -demo.

    go run ./examples/containers
    go run ./examples/containers -theme light
    
  • examples/stacked/{app.tui,theme.tcss} — the phase-1 media fixture (SPEC §4, §21 test 10): the inbox layout with static text, where @media (max-cols: 80) stacks #sidebar above #detail. It only feeds the goldens in testdata/golden/stacked/ (text dumps at 40/80/120 cols, not frozen); there is no main.go or sample.json, so it is not a runnable host.

Testing

  • go test ./... — the Go test suite: parser/CSS/layout/IR unit tests, the SPEC §21 conformance tests (conformance_test.go for tests 1-9, phase_test.go for tests 10-14, and the 0.2b literal fixtures and gates through the public API in conformance_v02b_test.go, conformance_p2_test.go, conformance_p3_test.go, conformance_p4_test.go, and the examples/monitor acceptance checks in conformance_p5_test.go), the formatter's idempotence/semantics-preservation tests, the IR-vs-schema test, the AGENTS.md-does-not-drift test, and the CLI's own exit-code/output tests. It also runs tuimark test testdata/golden itself (cmd/tuimark/main_test.go's TestTestCommandPasses), so a golden mismatch fails go test ./... (and task verify) same as any other test. go test -race ./... runs the same suite with the race detector.
  • go test -run xxx -bench BenchmarkFrame ./internal/host/ — SPEC §21 test 73 (recorded, not a gate): one 200×60 frame of a 1000-row, 7-column table, with a warm row cache (only the cursor moved), the same with every row checked and the cursor in the last rows, and a cold one (the rows resolved again, as after a Set of the array).
  • tuimark test — the golden dump runner described above, runnable on its own (./bin/tuimark test) for a faster loop while iterating; --update regenerates the non-frozen goldens for a human to review and commit. The spike goldens under testdata/golden/spike/ are frozen and never touched by --update.
  • glyph run specs/glyphrun/*.yml — terminal end-to-end specs driven by Glyphrun (the glyph CLI) against the real interactive hosts (examples/inbox, examples/dashboard, examples/monitor, examples/agent, and the help modal of every example that writes no version) and the tuimark CLI itself. Every binary the specs run must exist under bin/ first — task build builds bin/tuimark and all the example apps:
    task build
    
    The host_ts* specs also need examples/host-ts's dependencies, which include @thelacanians/tuimark copied from sdk/ts. Reinstall them from scratch so a stale copy never hides an SDK change:
    (cd examples/host-ts && rm -rf node_modules && bun install --frozen-lockfile)
    
    task glyph builds and then runs every spec; task verify runs fmt, vet, test, build, golden, and glyph in order — the full local gate. See glyphrun.config.yml and glyph docs for the spec format.

CI (.github/workflows/ci.yml) runs the same gate on every push and pull request to main: gofmt, go vet, go test -race, the builds, and the goldens on Linux (Go 1.22, the module's minimum, and Go 1.26) and macOS (Go 1.26), then every Glyphrun spec on Linux and macOS through .github/scripts/glyph-specs.sh, which runs all the specs and reports every failure instead of stopping at the first. Every spec runs offline: the agent specs use the scripted provider or the in-process loopback fixture.

Architecture

The pipeline a document goes through, from source to screen (SPEC §18):

parse .tui → source IR
apply stylesheet + media(cols, rows, theme)
inflate bindings + each + if
layout measure / allocate
focus + hit-test
paint buffer
diff previous frame → ANSI   # Run() only

Each frame is built in the order SPEC §18 fixes for 0.2b: (1) inflate the active screen and its open modals (if, hidden, text, class guards; a tabs' tab nodes but not their content), (2) cascade and drop display: none, (3) activate one tab per tabs, (4) resolve focus, repeating 1–4 while focus or an active tab changes (at most three rounds), (5) build the items of each hints, (6) measure and allocate, (7) generate the visible rows of each table, (8) paint. A table resolves every row in stage 1 (keys, guards, cell texts, the columns' measures) and gets its rows in stage 7, once layout knows its body height and offset. Stage 3 inflates only the active tab's content, so inactive tabs cost nothing; stage 5 runs the key dispatch without side effects to decide which hints apply; layout then picks the tab label tier and drops the hint items that do not fit.

Package map:

Package Role
internal/ir node, scalar, diagnostic, binding-grammar, and key-token types shared by the other packages
internal/parse the XML tokenizer (xml.go), the IR builder (build.go), the canonical formatter (format.go), and source-IR-as-JSON (irjson.go)
internal/css the TCSS parser, selectors, cascade, themes/tokens, and @media
internal/layout the integer flex engine (SPEC §11): box model, measure, allocate; layout: grid, the tab strip, and the hint items (widgets.go); the table (table.go)
internal/paint the cell grid, borders, titles, widgets, and the ANSI frame diff
internal/dump the text and JSON frame dump (SPEC §13)
internal/host the JSON store, bind/each/if inflation, cascade application, focus, the key dispatch of SPEC §8.6 with the built-in actions (dispatch.go, shared by Run, play, and the hints), tabs (tabs.go), hints and sparklines (hints.go), tables (table.go), event dispatch, and the Run loop
internal/agentsdoc generates AGENTS.md from the catalogs the packages above expose

cmd/tuimark is the CLI built on top of these packages; the root package tuimark (this module's public API, in tuimark.go) is a thin wrapper around internal/host.

Language notes (clarifications of the SPEC)

The SPEC is the source of truth and is not edited here. These are points where its text is loose or silent about behavior the runtime actually has; the generated AGENTS.md carries the same points, more tersely, in its ### Notes section. The full design-decision record (parser edge cases, TCSS cascade details, layout arithmetic, focus/signal handling, the CLI) is kept with the maintainer's project notes, outside this repository.

  • <text>/<button> body normalization (SPEC §6.3, §12). Each line of the body is trimmed of XML whitespace (space, tab, CR) and leading/trailing blank lines are dropped; interior spaces are kept, so <text>mail {folder}</text> keeps its two-space gap. Width (§11.5) and paint (§12) both see the already-trimmed content — <text> sync</text> measures 4 cells and paints sync, not sync. There is no XML-level way to force a leading or trailing space into the body (CDATA is trimmed the same way, and numeric character references are rejected outright); use gap, padding, or margin on a parent for spacing, or a non-XML space such as U+00A0 (NBSP) if you need it as literal content. Both points hold for the default wrap: nowrap (and the truncate* values) only: with wrap: wrap, a line is cut into words at every Unicode space (§11.5), so a run of spaces becomes one, and an NBSP is a break point painted as a plain space, not literal content.

  • Numbers in the store are float64 (SPEC §7, §18). The store holds JSON values, so Bind, Set, Batch, --data, and the host protocol's set/batch keep every number as a double: an integer beyond ±2^53 (a snowflake or database ID, say) is rounded, and {path} and Get show the rounded value. Pass such values as strings.

  • Modal visibility: open vs bind (SPEC §6.5, §7). A <modal>'s open attribute takes true, false, a bare path, or !path — never {path} (that gives V003); truthiness follows the §7 table. bind on a modal is shorthand for the same thing: a path whose truthiness opens it. Reading internal/host/frame.go's inflate for modal, the two are not merged or OR'd: if the open attribute is present at all, it alone decides visibility and bind is ignored outright, even when open's path is falsy and bind's path is truthy. bind is only consulted when open is absent. In practice, set exactly one of the two on a given modal.

  • Cross-axis stretch inside a modal (SPEC §11.2, §11.3 pass 4, §6.5). col/row default to 1fr on both axes, and by default children stretch to fill the cross axis (align: stretch). A modal in a document that writes version defaults to 80%×80% of the screen when unsized (in a <tui> without version it is as big as its content, and the row below grows only to a height the modal writes), so a <row> of buttons placed after other content inside it — the common confirm-dialog shape — inherits all of the modal's leftover height on the main axis, and its buttons then stretch to match on the cross axis. This is easy to miss in a dump's text grid (a button's label still reads as one line) but is very visible once a focused button has reverse: true, which paints its whole rect: a tall reversed block instead of a one-line button. Give the action row an explicit height: 1 or height: auto — align: start alone shrinks the buttons but leaves the row itself still filling the height. For example:

    <tui version="1">
      <style>
        modal { width: 30; height: auto; }
        #actions { height: 1; gap: 2; justify: center; }
      </style>
      <screen id="one" focus="#confirmDelete">
        <text>hi</text>
        <modal id="confirmModal" open="true">
          <text>Delete this item?</text>
          <row id="actions">
            <button id="confirmDelete" label="Delete"/>
            <button id="cancelDelete" label="Cancel"/>
          </row>
        </modal>
      </screen>
    </tui>
    

    Saved as modal.tui, this validates and dumps cleanly (./bin/tuimark validate modal.tui reports ok (bindings not checked: no --data); ./bin/tuimark dump modal.tui --cols 80 --rows 24 shows actions and both buttons at height 1, not stretched to the modal's height).

  • Built-in defaults beyond §10.6, and list/input have no border. §10.6 only lists defaults for screen col row box text. The runtime fills the rest of the gap with: scroll/list/item { layout: column }, modal { layout: column; border: single }, spacer { flex: 1 }, input, button, progress, rule { width: auto; height: auto }, and list > item:selected { reverse: true }. In particular, list and input get no default border (only modal does), so a border-color rule on either — including a :focus/:selected variant — changes nothing unless that same element (or selector) also sets border: single|double|rounded|thick somewhere. This is why the SPEC §17 inbox fixture's own theme.tcss (list:focus { border-color: $focus; }, input:focus { border-color: $focus; }) has no visible effect at all: dump --styles and play --input tab --styles produce byte-identical styles regardless of which of #query/#inbox is focused. border also needs 2 rows or columns of the element's own size, so a one-row <input> could not show one anyway; show focus on it with color, background, bold, or reverse instead — examples/agent and examples/dashboard both do this for button:focus { reverse: true; } — or give the element an explicit border: single first if a colored border is what you want, as examples/agent's #composer/#transcript and examples/dashboard's #pipeline/#mainpanel/#logScroll do (their list:focus/ scroll:focus { border-color: $focus; } works precisely because those elements already carry border: single). These three files stay byte-identical to SPEC §17 on purpose, so this note documents the trap rather than fixing the fixture.

  • Focused list key handling (SPEC §8.3). §8.3 says only "list consumes up/down", but a focused, non-empty list also consumes home, end, pgup, and pgdn — all six move the selection (firing on:select when it changes) and never reach the keymap while the list has focus. left/right are not consumed and fall through as usual. As with every other widget, j/k are never implicit for a list; bind them yourself in <keymap> if you want them.

  • List rows don't take focus individually. Inside <item>, nothing is focusable (no input/button/list/modal, no focusable, on:click, or on:focus) — using them inside an <item> is an error (V001 for those tags, V002 for those attributes). The <list> itself is the focusable unit and navigates its own rows. Act on a row via the list's on:select, or a keymap row scoped with when="#list:focus" that reads the event's keys/value to see which row fired.

  • Dump is a side-effect-free snapshot. It never moves focus, queues events, or mutates scroll/list/input state — same for Validate. Only the internal frame Run drives is "live".

  • Run restores the terminal on signals. On SIGTERM, SIGHUP, or SIGINT, Run puts the terminal back (cooked mode, main screen, visible cursor) and returns a non-nil error naming the signal, so the process can exit with a failure status instead of exiting clean.

  • ctrl+c is the built-in quit whenever no keymap binding claims it first; bind ctrl+c yourself to override or intercept it.

  • Escape latency is ~25ms. A trailing esc byte at the end of a read waits briefly for more bytes before it's delivered as the esc key, so a bare Escape press can be told apart from the start of a longer escape/CSI sequence.

  • Keys of one read see each other's changes (SPEC v0.2b §8.6). In a version="2" document, Run() and play dispatch each key or paste of one terminal read (or of one text: step) on a frame rendered again after the previous one changed anything the frame shows: an input's text or cursor, a list or table cursor, a viewport offset, a checked array, a tab, the screen. when selectors, class:NAME guards, and if see the change, so typing ab and enter in one read fires <bind keys="enter" action="go" when="#q.filled"/> on an input with class:filled="query" exactly as two separate reads do. A version="1" document keeps the 0.1 loop: the frame is rendered again between keys only when events fired or focus moved.

  • A tabs whose bind path cannot be written (SPEC v0.2b §6.10.2). When a value along the path is not an object (bind="ui.view" with ui a number), the active tab cannot change: switch-to, move-*, left/right, a label click, and the wheel change nothing and fire no on:select. A keymap row that names the tabs still takes its key.

  • A dropped mouse event also ends a pending press. A left release always ends the press before it; a press or release that the mouse ignores (outside the top modal, outside the grid, or while mouse is false) acts on nothing and leaves no press pending, so a later release never completes a click that started under a closed mouse or behind a modal. The wheel over a tab strip with no enabled tab stops there and changes nothing.

  • A viewport without an id keeps the offset the wheel gives it (SPEC v0.2b §8.5, wheel rule 3). Only the wheel can move one: it cannot take focus, and move-* needs an id. The same holds for a viewport inside a list row or an each template, where an id repeats. Its offset is kept under its element and the key of the row it is in, so it stays with that element when siblings before it come and go, and with that row when the rows are reordered. It starts at 0.

  • Layout defaults of the current language. A <tui> without version lays out with defaults a pinned document does not have, so the common shapes (a sidebar beside a body, a status bar, a confirm dialog) come out right without sizes. col and row are 1fr by 1fr only as a direct child of a screen, a modal, or a tab, and auto anywhere else: a sidebar col beside a body col is as wide as its content, and the body needs width: 1fr. A pinned document gives every col and row 1fr and splits such a row in equal halves. An unsized sibling of an fr child becomes 1fr only when it is a viewport (scroll, list, table, or overflow: scroll); a text or a button stays auto. fr meets min-* and max-* as in CSS: a child that a max clamps (or a min lifts) is frozen at that size and the other fr children share what it frees, so three 1fr children in 40 columns with max-width: 10 on the first are 10, 15, and 15, where a pinned document splits first and clamps after (10, 13, 14, leaving 3 columns empty). A hints is as big as its items along their axis and drops the items that do not fit, with no diagnostic. A modal with no size is as big as its content (wide enough for its title), capped by a written max-width or max-height and otherwise by 80% of the screen. An input with no width is max(width(placeholder) + 1, 20) columns whatever its value. The built-in sheet also gives table wrap: truncate, checkbox and select width: auto; height: auto; wrap: truncate, select > list border: single; max-height: 12, and button:focus, checkbox:focus, select:focus { reverse: true }, so a focused button is reversed with no author rule. tuimark inspect shows each of these rules as ua.

  • Keys and focus of the current language. In a <tui> without version, esc closes the top open modal that has no on:escape, by writing false at its open path (its bind path when it has no open); the next frame fires on:close and gives focus back, and no event fires for the key itself. A modal whose open is true, false, or !path, or whose path a Derive computes, stays open, because the runtime has nothing to write, and one with on:escape leaves the choice to that action. An open modal also traps the keymap: a row fires only when it has an effective when that matches the chain from the focused node up to the top modal (when="#dlg" inside the modal dlg, when="modal" inside any modal). A row with no when, or whose when names something outside the modal, is not tried, so q cannot quit the app while a confirmation is open. tab, shift+tab, ctrl+c, and the keys the focused widget takes for itself still work, and <hints/> shows only the rows that would fire. The way out for a key that should also work under a modal is <keymap when="modal">, a group whose rows all carry that when. A tabs is focusable without focusable="true": tab reaches it, left and right switch the tab, and focus on the strip stays there when the active tab changes (focusable="false" turns it off). ctrl+i, ctrl+j, and ctrl+m in a keys list are V003, because a terminal delivers them as tab and enter. A pinned document keeps the 0.4.1 behavior: its rows fire under a modal, esc does nothing without on:escape, and tabs takes focus only with focusable="true".

  • Composition. In a <tui> without version, <include src="FILE"/> places the elements of another file, whose root is <fragment>, where the include stands, so a long document is split by the pieces a person would name. <template id="T"> holds a piece of markup, and <use template="T" id="U" with="path as alias"/> places a copy of it: the copy's ids are scoped by the use (U__x; outside the copy you write the scoped id, inside it #x means the copy's own), and its data is behind an alias, so an input in the copy reads and writes the path the alias stands for. if, hidden, and a static class on a use apply to the copy where the use stands, once per row inside a list or an each. The dump, the keymap, focus, and events see the expanded document. The expansions share a budget (100000 elements, 16 MiB of placed source, 256 levels: V021); a cycle of templates is V019; a diagnostic about an element of a copy ends with (P via <use …>) or , placed by <use …> at FILE:LINE:COL. tuimark inspect ends with the source of a node and a via line for each use or include that placed it, and tuimark ir gives each placed node a from. A fragment is not a document: validate (ok (fragment checked on its own)) and fmt accept it, and the other commands give it V005.

Status per phase (SPEC §4)

  • Phase 0 (spike) — done: app col row box text, goldens frozen and green.
  • Phase 1 (style) — done: TCSS parser, tokens/themes, one-feature @media, preview --watch.
  • Phase 2 (interaction) — done: JSON store, {path}/each/if, list item input button scroll spacer, focus/tab, named actions.
  • Phase 3 (chrome) — done: keymap bind progress rule modal, screen focus, validate --strict --catalog, Run().
  • Phase 4 (agent polish) — done: fmt, ir, test, agents, dump --cells, the examples/agent harness, this README.
  • Phase 5 (v0.2a: runtime, terminal, and tools) — grapheme-cluster width (segmentation by github.com/rivo/uniseg) and the wide/--cells dump fields; auto-fit scroll/list sizing with the L006 diagnostic; Run()'s capability probe, color profiles, theme selection, bracketed paste, and input decoder (SPEC §26); and the headless tools this delivery added — tuimark play, dump --styles, --theme on dump/validate/preview/play, the tuimark test --update superset check, and schema/ir.v0.2.json/schema/dump.v0.2.json. No 0.2a document changes version (tuimark ir still emits "version": "0.1"); <tui version="2"> and the rest of the new vocabulary are 0.2b.
  • Phase 6 (v0.2b) — implemented; the SPEC §24 trial with a fresh agent (§21 test 74) is still to be run and recorded. Done: the version="2" gate for the whole 0.2b vocabulary (with the (requires version="2") hint in version="1" documents), IR 0.2 and the 0.2b schemas, class:NAME and the dump's classes, the SPEC §18 frame order, theme="auto" (OSC 11, COLORFGBG), @media (theme), Set("@theme"), tuimark inspect, TUIMARK_LOG, the written-out key dispatch with the built-in actions and when over the focus chain, each on containers, multi-select on list and table, :checked/:focus-within, table/column (with the table golden at 30x5, the SPEC §6.9.5 fixture in specs/fixtures/table.{tui,json}), scrollbar, bar: eighths, tabs/tab (with focus on activation and focus requests into inactive tabs), sparkline, hints with label/keycap, and layout: grid (goldens tabs, hints, hints-filter, and grid, the SPEC literal fixtures in specs/fixtures/), the mouse (SGR reports, click on release, the wheel, play's click:/wheel-*: steps), and the acceptance fixture examples/monitor with its monitor-* goldens and specs/glyphrun/monitor_*.yml specs.

License

MIT — see LICENSE.

Documentation

Overview

Package tuimark is a view language for terminals.

Humans and coding agents author UI as text — structure in .tui (XML), style in .tcss (reduced CSS), data as JSON, behavior as named actions — and this runtime interprets it: layout on an integer cell grid, Unicode paint, and a machine-readable dump.

ui, err := tuimark.Load("app.tui")
if err != nil { return err }
_ = ui.Bind("tickets", tickets)
ui.On("open", handleOpen)
ui.On("quit", func(tuimark.Event) error { return tuimark.ErrQuit })
return ui.Run(os.Stdout)

The public surface is deliberately small: Load, LoadFS, Parse, Bind, Set, Get, Batch, On, Catalog, Dump, Validate, Play, Run, RunContext, RunWith, Quit, Every, Derive.

Index

Constants

This section is empty.

Variables

View Source
var ErrQuit = host.ErrQuit

ErrQuit stops Run cleanly when returned by a handler.

Functions

This section is empty.

Types

type ActionSpec

type ActionSpec = host.ActionSpec

ActionSpec describes an action the document references.

type App

type App struct {
	// contains filtered or unexported fields
}

App is a loaded document plus its data and runtime state.

func Load

func Load(path string) (*App, error)

Load reads a .tui document from disk. Diagnostics in the document do not fail Load; read them with Validate or Dump. The error is for I/O.

func LoadFS added in v0.3.0

func LoadFS(fsys fs.FS, name string) (*App, error)

LoadFS reads a .tui document named name from fsys and is otherwise Load: name must satisfy fs.ValidPath, or the error wraps fs.ErrInvalid; a read error is returned as an I/O error, like Load's. A relative <style src> resolves inside fsys as path.Join(path.Dir(name), src); an absolute src, or one whose joined path climbs above fsys's root, is a V006 diagnostic ("style src=... is outside the file system") instead of an I/O error, so the document still loads and reports it; any other src failure (missing, a .tui, included twice) is V006 as for Load. Diagnostics name the document and its stylesheets by their base names, as Load does, so the same files give the same diagnostics through either function. Load itself is unchanged: it still resolves src against the directory of the file on disk, .. included.

func Parse

func Parse(r io.Reader) (*App, error)

Parse reads a .tui document from r. Relative <style src> paths resolve against the working directory.

func (*App) Batch added in v0.3.0

func (a *App) Batch(fn func(b *Batch) error) error

Batch calls fn once, with a fresh *Batch that runs without the app's lock, so fn may call Get, Set, or an inner Batch (which applies on its own, before this one, since neither goes through b). Every b.Set call is validated as Set validates it (JSON coercion, path grammar, the reserved paths' values); when fn returns nil and every b.Set succeeded, the queued writes are applied in order, atomically — no frame, dump, Get, handler, or Play step ever observes some of them applied and others not — with at most one redraw request.

When fn returns an error, or any b.Set failed (even if fn ignored it and returned nil), nothing is applied and Batch returns every such error, joined with errors.Join (SPEC v0.3b §18.1): first each failed b.Set's own error, in the order the calls were made, then fn's own error unless it is == to one of those (so returning a failed b.Set's own error reports it once, not twice). When fn and every b.Set succeeded but applying the queued writes failed (a path running through a value the batch itself left not an object), that one error is the joined value's only part. The returned error is always an errors.Join value, even for a single failure, so err == sentinel, a switch err, and a type assertion on it no longer match that failure; use errors.Is and errors.As instead, and Unwrap() []error to walk every part in order. A b.Set called on a *Batch kept after fn has returned returns an error and changes nothing; Batch has returned by then, so that error reaches only the caller of that b.Set and is never one of the parts of the error Batch returned. Batch is safe to call from any goroutine, including a handler.

func (*App) Bind

func (a *App) Bind(path string, v any) error

Bind stores v at a dotted path in the JSON store. v is coerced to JSON (null, bool, number, string, array, object). The empty path replaces the whole store with an object. The store is JSON-shaped, so every number is kept as a float64: an integer beyond ±2^53 (a snowflake ID, say) loses precision, and Get returns the rounded value. Pass such values as strings.

func (*App) Catalog

func (a *App) Catalog() []ActionSpec

Catalog lists the actions the document references, with where they are used and whether a handler is registered.

func (*App) Derive added in v0.4.0

func (a *App) Derive(path string, fn func(get func(path string) (any, bool)) (any, error)) error

Derive declares path a derived value: the result of fn over the store, kept in the store at path and recomputed by the runtime, so markup reads a filtered list or a count as any other path while staying free of expressions. path must be a non-empty store path in Set's grammar; a reserved "@" path, a path inside or above a derived path, and a path Set could not write in the current store are errors, and register nothing. There is no way to remove a derived value.

fn runs once when registered, then after every change to the store, as part of that change: a Set, Bind, or Batch (once per Batch, after all its writes, so get sees every write of the batch), and every write the runtime makes (an input's bind, a list or table cursor's bind, a checked array, a tab's bind, the tab activations of a focus request and their undo). Nothing observes a store whose derived values are stale: not Get, Dump, a frame, a handler, nor a Play step. Derived values are recomputed in registration order, so a later one reads the earlier ones' new values. get has Get's results (a deep copy; (nil, false) for a missing or malformed path) over the store as the write left it, and (nil, false) for a reserved "@" path. fn's value is coerced to JSON as Bind coerces it. fn runs while the app holds its lock: calling any method of the App from fn never returns, and other goroutines' Get, Set, and Batch wait while it runs. A panic in fn is not recovered.

When fn returns an error, or a value that cannot be coerced, null is written at path and the error is reported once: while Run is active, on the loop at its next settle, as a handler error with no event (so without RunOptions.OnError it ends Run); while Play is active, as a handler error of the step being applied; otherwise at the start of the next Run or Play (only the first such error is kept until then). The Set, Bind, or Batch that caused it returns nil. Dump and Validate never report it.

A Set, Bind, or b.Set whose path is a derived path, or lies inside one, returns an error and changes nothing (a Batch holding one applies none of its writes). A write to an ancestor, Set("", ...) included, is allowed and followed by the recomputation, but fails as a whole, naming the derived path and changing nothing, when the recomputation could not write it. For the runtime a derived path is unwritable: an input, list, table, checked array, or tabs bound to one does not change, fires no on:change or on:select, and a built-in keymap row that targets it does not match, so the key goes on to the next rows. Derive is safe from any goroutine, during Run too.

func (*App) Dump

func (a *App) Dump(cols, rows int) (*Dump, error)

Dump renders the current state at cols×rows. It is a snapshot: it does not move focus, fire events, or change what Run starts from, so dumps at several sizes do not depend on their order. A negative size, or one above 1000 in either dimension (the limit Play and the CLI share), is an error; 0 gives an empty grid. The dump's grid always has exactly rows lines of cols cells.

func (*App) Every added in v0.4.0

func (a *App) Every(d time.Duration, fn func(b *Batch) error) (stop func())

Every arranges for fn to be called every d while Run is active. Each call gets a fresh *Batch whose writes apply as one Batch, with one redraw request. fn runs on the loop's goroutine, between inputs, as a handler does (a slow fn delays keys as a slow handler does), and each tick is a settle: the frame is drawn and the queued lifecycle events run. A tick that falls due while the loop is busy runs once when it is free (ticks are never queued, as with time.Ticker), and registrations due at the same moment run in the order they were registered. An error fn returns, or a failed write of its batch, is a handler error with no event: a quit (errors.Is(err, ErrQuit)) ends the session, RunOptions.OnError gets any other error with ev == nil, and without OnError it ends Run.

Every may be called before Run or during it: a registration made during a session counts its first tick d after the call, and each session counts the first tick of every other live registration d after it starts. Registrations persist across Run calls until stopped. Dump and Validate never call fn. Play calls it only on a tick:DUR step, on a virtual clock with deadlines of its own (a registration made while Play runs takes its first virtual deadline d after the virtual instant it is made), so a Play without tick steps never calls it and Run's real-time deadlines are separate; a test drives time-based state with tick: or set: steps. A d of zero or less panics, as time.NewTicker does.

stop cancels the registration: it is idempotent, safe from any goroutine (fn included), and never waits. Once it returns, no new call of fn starts; a call already running finishes and its batch applies.

func (*App) Get added in v0.3.0

func (a *App) Get(path string) (any, bool)

Get returns a deep copy of the value at path, JSON-shaped (nil, bool, float64, string, []any, or map[string]any): mutating the returned value never changes the store. path has the grammar of Bind and Set (dotted, numeric segments index arrays); the empty path returns a copy of the whole store. A path that does not resolve, or is malformed, returns (nil, false).

The three reserved Set paths return the app's current state instead of reading the store:

  • "@focus" is "#id" for the runtime's current focus — moved by every input and focus request applied since the last frame Run or Play built, undone when the next frame rejects it — or nil when nothing is focused or no frame has been built yet (Dump builds no live frame).
  • "@screen" is the active screen's id, or nil when it has none.
  • "@theme" is "dark" or "light": the session's theme while Run is active, else the theme Dump uses (the host's @theme, else the document's, with auto as dark).

Any other path, another "@" one included, reads the store, where Set writes it. Get is safe to call from any goroutine, including a handler.

func (*App) On

func (a *App) On(action string, h Handler)

On registers the handler for a named action.

func (*App) Play added in v0.3.0

func (a *App) Play(opts PlayOptions, steps ...string) (*PlayResult, error)

Play drives the app itself, headless, exactly as Run would from the same input, and returns what happened. The app's state persists across calls: a later Play or Run continues from it, and Dump/Validate leave it untouched. Every call starts with step 0: the live frame is rendered at the size and settled, dispatching any lifecycle events a previous call left queued; steps 1..n are then applied and settled the same way. Each string in steps is exactly one step of tuimark play's --input grammar (a key token, or text:/paste:/set:/focus:/resize:/ click:/wheel-up:/wheel-down:/tick:) — nothing splits it on spaces, so "text:hello world" types a space — and every step is parsed before any is applied: an invalid one returns an error naming its index and step, a nil result, and does nothing, step 0 included.

tick:DUR is the clock step (v0.4b): it advances the app's virtual clock by DUR (a whole number of milliseconds from 1ms to 1h, written with the units ms, s, m, and h, such as "tick:250ms"; a caller holding a time.Duration writes "tick:" + d.String()), which persists across Play calls, calls the Every functions that fall due on it unless opts.NoHandlers is set (as many as 10000 per step), and moves the spinners of the frames Play builds (a spinner paints phase floor(V/100ms); Dump paints phase 0). An Every function's ErrQuit ends the call with Quit: true and any other error ends it with that error, as a handler's does.

Unless opts.NoHandlers is set, the handler registered with On for a dispatched action is called synchronously, in the order Run's loop would call it. A handler that returns ErrQuit, or the built-in quit with no handler, ends the call with Quit: true: the remaining events of that round and the remaining steps are not applied, and lifecycle events already queued stay queued for the next call's step 0. A handler that returns any other error ends the call the same way, and Play returns that error together with the result so far (both non-nil). A step that cannot be applied when its turn comes (a set: Set refuses, a mouse cell outside the grid) also ends the call: Play returns the error "step N (STEP): reason" together with the result so far, and the earlier steps stay applied.

The theme is the one Dump uses (the host's @theme, else the document's, with auto as dark); Play never probes a terminal or reads an environment variable. Play and Run exclude each other: a call made while Run or another Play is active on the app returns an error, a nil result, and does nothing; Run called while Play is active returns an error before it looks at the terminal.

With opts.Frames set (v0.3b), the result's Frames holds one PlayFrame per step applied in this call, step 0 included, in order: a step's frame is the frame settled after it, or, for a step that ended the call (a quit or a handler error), the frame in effect at that moment — that step still gets an entry, and its Events hold the event that ended the call. A step that could not be applied has no entry. Without opts.Frames, Frames is nil and nothing extra is built. tuimark play --frames and Play(opts.Frames: true, ...) give the same frames, member for member, for the same steps and flags (SPEC v0.3b §18.1 rule 9).

func (*App) Quit added in v0.4.0

func (a *App) Quit()

Quit asks the active Run, RunContext, or RunWith to end. The request is taken at the same stop checks as a cancellation, with the same effect, and Run returns nil. A Quit made from a handler takes effect when that handler returns, so the events after it in the round are not dispatched, exactly as when the handler returns ErrQuit. It is safe from any goroutine. Called while no Run is active it does nothing, and a later Run does not see it; Play ignores it.

func (*App) Run

func (a *App) Run(w io.Writer) error

Run drives the app in the terminal until quit. Input is read from stdin in raw mode; frames are written to w. It returns nil on quit, and nothing is left reading stdin when it returns. A version="2" document's mouse attribute turns mouse reporting on while it is true (left clicks and the wheel act; see the SPEC's mouse section). Printable keys that arrive together for the focused input (a paste) are typed as one edit with one on:change carrying the final value. SIGTERM, SIGHUP, and SIGINT also stop it: the terminal is restored first (cooked mode, main screen, visible cursor) and Run returns a non-nil error naming the signal, so the program can exit with a failure status, even when a handler returned ErrQuit as the signal came in. A handler that is still running when the signal arrives does not hold the terminal: it is restored right away, and if Run has not returned within a second, or a second signal arrives, the process ends with the signal's default action. Run(w) is RunContext(context.Background(), w).

func (*App) RunContext added in v0.4.0

func (a *App) RunContext(ctx context.Context, w io.Writer) error

RunContext is RunWith(ctx, w, RunOptions{}).

func (*App) RunWith added in v0.4.0

func (a *App) RunWith(ctx context.Context, w io.Writer, opts RunOptions) error

RunWith is Run with a context and options. When ctx is already done as RunWith is called, it returns an error wrapping ctx.Err() without touching the terminal (the Run/Play exclusion is checked first). A cancellation during the session is taken at the loop's stop checks, the points where it takes a stop signal (before each input of a read, and between one event and the next), and acts as a quit: the remaining events of the round and inputs of the read are not dispatched, no further frame is drawn, the terminal is restored, and RunWith returns an error for which errors.Is(err, ctx.Err()) holds. The signal-only rules of Run (the forced restore, the one-second grace) do not apply: a handler that blocks keeps the session, and the terminal, until it returns, so pass the context on to handlers that wait. TUIMARK_LOG's end record gives "error" for a cancellation and "quit" for a quit that opts.OnError returned. Without opts.OnError, an error that is not a quit ends the session, as in Run.

func (*App) Set

func (a *App) Set(path string, v any) error

Set is Bind for a running app: it also schedules a redraw and is safe to call from any goroutine. The reserved paths "@focus" ("#id") and "@screen" ("id") move focus and switch screens; "@theme" ("dark", "light", or "auto") sets the host's theme, which beats the document's theme attribute in Dump, Validate, and Run (TUIMARK_THEME still wins in Run); any other value is an error and changes nothing. Values are coerced as Bind coerces them: numbers become float64, so integers beyond ±2^53 lose precision (pass them as strings).

func (*App) Validate

func (a *App) Validate() []Diagnostic

Validate returns every diagnostic: parse and stylesheet problems in the whole document (every screen, closed modals, false if= branches, list templates), and layout/bind problems at 40, 80, and 120 columns.

type Batch added in v0.3.0

type Batch = host.Batch

Batch is a set of Set calls Batch applies together; see App.Batch.

type Diagnostic

type Diagnostic = ir.Diagnostic

Diagnostic is a parse, layout, or bind problem.

type Dump

type Dump = dump.Dump

Dump is a rendered frame: grid (no ANSI), node geometry, and diagnostics.

type DumpNode

type DumpNode = dump.Node

DumpNode is one laid-out node in a Dump.

type Event

type Event = host.Event

Event is the payload a handler receives: the action name, the id of the node that fired it, each-alias keys of the selected list item, and the input value for change/submit.

type Handler

type Handler = host.Handler

Handler handles a named action. Return ErrQuit to stop Run.

type PlayEvent added in v0.3.0

type PlayEvent = dump.Event

PlayEvent is one action Play dispatched; it marshals exactly as tuimark play's events do: {"step","action","source","keys","value"}.

type PlayFrame added in v0.3.1

type PlayFrame = dump.PlayFrame

PlayFrame is one applied step's settled frame, present in PlayResult.Frames only when PlayOptions.Frames was set (v0.3b, SPEC §18.1): it marshals exactly as tuimark play --frames's frames entries do, {"step","input","events","dump"}. Step is the step's number (0 for the live frame Play renders before any step); Input is the step's text as given ("" for step 0); Events holds only the events that step fired (never nil); Dump is that step's settled frame, built with PlayOptions.Styles/Cells exactly as PlayResult.Dump is.

type PlayOptions added in v0.3.0

type PlayOptions struct {
	Cols, Rows int
	NoHandlers bool
	Styles     bool
	Cells      bool
	Frames     bool
}

PlayOptions configures Play. Cols and Rows each default on their own (0 is 80 for Cols, 24 for Rows, as for tuimark play); after defaulting each must be 1-1000. NoHandlers records host actions without calling their registered handlers, as tuimark play always does (a CLI-loaded app never has any). Styles and Cells add the dump's theme/styles and cells fields, as the --styles and --cells flags do. Frames (v0.3b) makes Play also return one PlayFrame per applied step in PlayResult.Frames, as play --frames does.

type PlayResult added in v0.3.0

type PlayResult struct {
	Events []PlayEvent
	Dump   *Dump
	Quit   bool
	Frames []PlayFrame
}

PlayResult is what one Play call did: Events holds every event dispatched during the call, in order, step 0 included; Dump is the last frame built, at the size in effect then; Quit reports whether the call ended with a quit. Frames (v0.3b) holds one PlayFrame per step applied in this call, step 0 included, in the same order, only when PlayOptions.Frames was set; otherwise it is nil.

type RunOptions added in v0.4.0

type RunOptions struct {
	// OnError, when set, is called on the loop's goroutine with each error
	// that a handler, an Every function, or a Derive function returns and
	// that is not a quit (errors.Is(err, ErrQuit)). ev is the event whose
	// handler failed, or nil for Every and Derive. Its result decides what
	// follows: nil continues the session (the failed handler counts as
	// having returned nil, so the rest of the round, the later lifecycle
	// rounds, and the rest of the read are dispatched), a quit ends it as
	// a quit (RunWith returns nil), and any other error ends it with that
	// error.
	OnError func(ev *Event, err error) error
}

RunOptions are RunWith's options.

Directories

Path Synopsis
cmd
tuimark command
Command tuimark validates, dumps, previews, plays, and formats Tuimark documents.
Command tuimark validates, dumps, previews, plays, and formats Tuimark documents.
tuimark-wasm command
Command tuimark-wasm is the Tuimark runtime compiled to WebAssembly for the website's playground (ADR 0033).
Command tuimark-wasm is the Tuimark runtime compiled to WebAssembly for the website's playground (ADR 0033).
examples
2048 command
Command 2048 runs examples/2048/app.tui: the sliding-tiles game, a demonstration of nested container each, class: guards, and App.Every.
Command 2048 runs examples/2048/app.tui: the sliding-tiles game, a demonstration of nested container each, class: guards, and App.Every.
agent command
Engine wires a Provider and a Tools sandbox to a loaded tuimark.App entirely through the public package: tuimark.Load, Bind, Set, On, Dump, Run.
Engine wires a Provider and a Tools sandbox to a loaded tuimark.App entirely through the public package: tuimark.Load, Bind, Set, On, Dump, Run.
chess command
Command chess runs examples/chess/app.tui: a full chess game for the terminal, a demonstration of a clickable 64-cell board, App.Every as a clock, and a search that runs off the loop and applies its result with App.Batch.
Command chess runs examples/chess/app.tui: a full chess game for the terminal, a demonstration of a clickable 64-cell board, App.Every as a clock, and a search that runs off the loop and applies its result with App.Batch.
containers command
Command containers runs examples/containers/app.tui: a container-engine dashboard in the spirit of Docker Desktop or k9s, built on the Tuimark kit, over a simulated engine (no Docker is touched).
Command containers runs examples/containers/app.tui: a container-engine dashboard in the spirit of Docker Desktop or k9s, built on the Tuimark kit, over a simulated engine (no Docker is touched).
dashboard command
Command dashboard runs examples/dashboard/app.tui as an interactive terminal app: a deploy pipeline chrome showcase.
Command dashboard runs examples/dashboard/app.tui as an interactive terminal app: a deploy pipeline chrome showcase.
gitui command
Command gitui runs examples/gitui/app.tui, a small git client in the spirit of lazygit and tig: Status (staged and unstaged files with a diff preview), Log (a filterable commit table with a commit detail), and Branches (with a checkout confirmation).
Command gitui runs examples/gitui/app.tui, a small git client in the spirit of lazygit and tig: Status (staged and unstaged files with a diff preview), Log (a filterable commit table with a commit detail), and Branches (with a checkout confirmation).
http command
Command http runs examples/http/app.tui, a small API client in the spirit of Postman or Insomnia: a collection of saved requests and a history of sent ones, an editor (method, URL, headers, body), and a response pane with the status, timing, the pretty-printed body, and a table of headers.
Command http runs examples/http/app.tui, a small API client in the spirit of Postman or Insomnia: a collection of saved requests and a history of sent ones, an editor (method, URL, headers, body), and a response pane with the status, timing, the pretty-printed body, and a table of headers.
inbox command
Command inbox runs examples/inbox/app.tui as an interactive terminal app.
Command inbox runs examples/inbox/app.tui as an interactive terminal app.
kit-gallery command
Command kit-gallery runs examples/kit-gallery/app.tui, a gallery of every component in kit/ (badges, buttons, alerts, cards, stats, progress, forms, a data table, a log panel, dialogs, a command palette, toasts, a help overlay, a master/detail split) on five tabs.
Command kit-gallery runs examples/kit-gallery/app.tui, a gallery of every component in kit/ (badges, buttons, alerts, cards, stats, progress, forms, a data table, a log panel, dialogs, a command palette, toasts, a help overlay, a master/detail split) on five tabs.
logs command
Command logs runs examples/logs/app.tui: a live log viewer in the spirit of tail -f and a small lnav.
Command logs runs examples/logs/app.tui: a live log viewer in the spirit of tail -f and a small lnav.
minesweeper command
Command minesweeper runs examples/minesweeper/app.tui: Minesweeper with the mouse and the keyboard.
Command minesweeper runs examples/minesweeper/app.tui: Minesweeper with the mouse and the keyboard.
monitor command
Command monitor runs examples/monitor/studio.tui, the Tuimark 0.2b acceptance fixture: three tabs of monitor studio (Overview, Processes with the kill confirmation and the process detail, Settings), plus CPU for the core grid.
Command monitor runs examples/monitor/studio.tui, the Tuimark 0.2b acceptance fixture: three tabs of monitor studio (Overview, Processes with the kill confirmation and the process detail, Settings), plus CPU for the core grid.
music command
Command music runs examples/music/app.tui: a terminal music player in the spirit of cmus, with no audio.
Command music runs examples/music/app.tui: a terminal music player in the spirit of cmus, with no audio.
pipeline command
Command pipeline runs examples/pipeline/app.tui: a CI pipeline runner in the spirit of GitHub Actions or Buildkite.
Command pipeline runs examples/pipeline/app.tui: a CI pipeline runner in the spirit of GitHub Actions or Buildkite.
pomodoro command
Command pomodoro runs examples/pomodoro/app.tui: a big-digit clock and Pomodoro timer.
Command pomodoro runs examples/pomodoro/app.tui: a big-digit clock and Pomodoro timer.
snake command
Command snake runs examples/snake/app.tui: Snake in the terminal, a demonstration of App.Every as a game loop.
Command snake runs examples/snake/app.tui: Snake in the terminal, a demonstration of App.Every as a game loop.
sudoku command
Command sudoku runs examples/sudoku/app.tui: Sudoku with a generator, pencil marks, hints, undo, and a clock.
Command sudoku runs examples/sudoku/app.tui: Sudoku with a generator, pencil marks, hints, undo, and a clock.
tasks command
Command tasks runs examples/tasks/app.tui, a kanban board: three lanes (Todo, Doing, Done) of cards you move between lanes, reorder, mark in bulk, add, edit, delete, and filter.
Command tasks runs examples/tasks/app.tui, a kanban board: three lanes (Todo, Doing, Done) of cards you move between lanes, reorder, mark in bulk, add, edit, delete, and filter.
tetris command
Command tetris runs examples/tetris/app.tui: falling blocks in the terminal, a demonstration of App.Every as a game loop and of a board drawn from data.
Command tetris runs examples/tetris/app.tui: falling blocks in the terminal, a demonstration of App.Every as a game loop and of a board drawn from data.
internal
agentsdoc
Package agentsdoc generates AGENTS.md from the runtime's own catalogs (tag kinds, attributes, CSS properties, key tokens, diagnostic codes) so the document agents read cannot drift from what the code actually accepts.
Package agentsdoc generates AGENTS.md from the runtime's own catalogs (tag kinds, attributes, CSS properties, key tokens, diagnostic codes) so the document agents read cannot drift from what the code actually accepts.
catalog
Package catalog builds the output of `tuimark catalog` (SPEC v0.4a §15.10): the closed vocabulary as one JSON-ready value, generated from the runtime's own tables so an editor, a language client, or an agent reads the catalog instead of scraping AGENTS.md.
Package catalog builds the output of `tuimark catalog` (SPEC v0.4a §15.10): the closed vocabulary as one JSON-ready value, generated from the runtime's own tables so an editor, a language client, or an agent reads the catalog instead of scraping AGENTS.md.
css
Package css implements TCSS v1 (SPEC §10): a reduced, cell-based CSS with type/#id/.class/child/pseudo selectors, theme tokens, one-feature @media blocks, and a cascade that computes a typed style per node.
Package css implements TCSS v1 (SPEC §10): a reduced, cell-based CSS with type/#id/.class/child/pseudo selectors, theme tokens, one-feature @media blocks, and a cascade that computes a typed style per node.
dump
Package dump builds the machine-readable frame snapshot (SPEC §13.2) and its human text form (§13.3).
Package dump builds the machine-readable frame snapshot (SPEC §13.2) and its human text form (§13.3).
host
Package host owns a running document: the JSON store, stylesheets, focus, list/input state, events, and the terminal loop.
Package host owns a running document: the JSON store, stylesheets, focus, list/input state, events, and the terminal loop.
ir
Package ir holds the source IR shared by the parser, the stylesheet engine, the layout engine, and the dump: nodes, sizes, and diagnostics.
Package ir holds the source IR shared by the parser, the stylesheet engine, the layout engine, and the dump: nodes, sizes, and diagnostics.
layout
Package layout is Tuimark's own layout engine (SPEC §11): integer cells, one allocation pass per container, border included in the outer rect.
Package layout is Tuimark's own layout engine (SPEC §11): integer cells, one allocation pass per container, border included in the outer rect.
lsp
Package lsp is `tuimark lsp` (SPEC v0.4a §15.12): a Language Server Protocol 3.17 server over stdin/stdout for .tui and .tcss files.
Package lsp is `tuimark lsp` (SPEC v0.4a §15.12): a Language Server Protocol 3.17 server over stdin/stdout for .tui and .tcss files.
paint
Package paint turns a laid-out Box tree into a cols×rows cell grid: backgrounds, borders, titles, and widget content.
Package paint turns a laid-out Box tree into a cols×rows cell grid: backgrounds, borders, titles, and widget content.
parse
Package parse turns a .tui document into the source IR.
Package parse turns a .tui document into the source IR.
play
Package play is the headless replay engine of SPEC §15.4: it serves tuimark play, tuimark test, and (SPEC v0.3 §18.1) tuimark.Play, so their parity is guaranteed by construction (one engine, one place the steps are applied).
Package play is the headless replay engine of SPEC §15.4: it serves tuimark play, tuimark test, and (SPEC v0.3 §18.1) tuimark.Play, so their parity is guaranteed by construction (one engine, one place the steps are applied).
playground
Package playground is the engine behind the website's browser playground (ADR 0033): sessions of the Tuimark runtime over an in-memory file system, driven through JSON calls.
Package playground is the engine behind the website's browser playground (ADR 0033): sessions of the Tuimark runtime over an in-memory file system, driven through JSON calls.
uniwidth
Package uniwidth measures text the way Tuimark lays it out and paints it (SPEC v0.2 §11.5.1): as a sequence of extended grapheme clusters (UAX #29, segmented by github.com/rivo/uniseg, Unicode 15.0.0), each 0, 1, or 2 columns wide by a Tuimark-owned width function.
Package uniwidth measures text the way Tuimark lays it out and paints it (SPEC v0.2 §11.5.1): as a sequence of extended grapheme clusters (UAX #29, segmented by github.com/rivo/uniseg, Unicode 15.0.0), each 0, 1, or 2 columns wide by a Tuimark-owned width function.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL