# Tuimark: the complete documentation > Tuimark is a view language for terminals: UI as .tui markup, .tcss styles and JSON data, interpreted by a small Go runtime that lays out a cell grid and dumps every frame as JSON for coding agents. Drive it from Go, TypeScript, or any language. Source: https://tuimark.dev. The index of these pages is https://tuimark.dev/llms.txt. --- # Getting started URL: https://tuimark.dev/guide/getting-started > Install the tuimark CLI, write a first document, check it with validate, dump, preview and play, and try it live with tuimark run. Install the CLI, write a three-file document, and look at it the way an agent does: as a validated, dumped frame. Then run it for real from a few lines of Go. ## Install [#install] Check that it is on your `PATH`: ```sh tuimark version ``` ## Your first document [#your-first-document] A Tuimark view is text. Make a directory with three files: the structure (`app.tui`), the style (`theme.tcss`), and some data to render it with (`sample.json`). ```tui tab="app.tui" ``` ```term $ tuimark validate broken.tui error V003 broken.tui:3:14 width: bad size "30px" (want N, N%, Nfr, or auto) error V001 broken.tui:6:5 /screen/lsit unknown tag ; did you mean ? error V012 broken.tui:7:5 /screen/input is focusable and needs an id ``` The codes are listed in the [diagnostics reference](/reference/diagnostics). The exit status is 2, so a script or an agent can tell right away. ## Look at it [#look-at-it] `dump` renders one frame at a size you choose and prints it: the grid of cells, then every laid-out node with its size and position, then the diagnostics. ```term $ tuimark dump app.tui --data sample.json --cols 48 --rows 10 === grid 48x10 === launch checklist ╭──────────────────────────────────────────────╮ │write the release notes │ │tag v0.2.0 │ │publish the docs │ ╰──────────────────────────────────────────────╯ q quit enter open === nodes === main screen 48x10 @(0,0) title text 48x1 @(0,0) tasks list 48x5 @(0,1) focus - item 46x1 @(1,2) selected - text 46x1 @(1,2) - item 46x1 @(1,3) - text 46x1 @(1,3) - item 46x1 @(1,4) - text 46x1 @(1,4) keys hints 48x1 @(0,9) - row 6x1 @(0,9) - text 1x1 @(0,9) - text 4x1 @(2,9) - row 10x1 @(8,9) - text 5x1 @(8,9) - text 4x1 @(14,9) === errors === ok ``` This is the whole point of Tuimark: the frame is data. `--format json` prints the same thing as JSON for a program to read, and the [dump format](/guide/tools#dump) is stable and versioned. The text grid has no colors. To see them, use `preview`, which paints the frame with ANSI colors when its output is a terminal, and `--watch` repaints it whenever you save: ```sh tuimark preview app.tui --data sample.json --cols 80 --rows 12 --watch ``` ## Drive it [#drive-it] `play` replays input against the document with no terminal at all, and prints the frame it ends on plus every action that fired. Here the list moves down twice, and `enter` fires `open`: ```term $ tuimark play app.tui --data sample.json --cols 48 --rows 10 --input "down down enter" === grid 48x10 === launch checklist ╭──────────────────────────────────────────────╮ │write the release notes │ │tag v0.2.0 │ │publish the docs │ ╰──────────────────────────────────────────────╯ q quit enter open === nodes === main screen 48x10 @(0,0) title text 48x1 @(0,0) tasks list 48x5 @(0,1) focus - item 46x1 @(1,2) - text 46x1 @(1,2) - item 46x1 @(1,3) - text 46x1 @(1,3) - item 46x1 @(1,4) selected - text 46x1 @(1,4) keys hints 48x1 @(0,9) - row 6x1 @(0,9) - text 1x1 @(0,9) - text 4x1 @(2,9) - row 10x1 @(8,9) - text 5x1 @(8,9) - text 4x1 @(14,9) === errors === ok === events === 1 moved tasks {"t":"t2"} null 2 moved tasks {"t":"t3"} null 3 open tasks {"t":"t3"} null ``` Each event line reads `STEP ACTION SOURCE KEYS VALUE`: at step 3, `open` fired from the list `tasks`, and `keys` names the row the cursor was on (`t` is the `each` alias). The `selected` marker moved to the third item, and `moved` fired on each step that moved the cursor. `play` never runs host code: host actions are only recorded. That is what makes it safe to run anywhere, and exactly what you need to check a keymap before any Go exists. ## Try it live, without Go [#try-it-live-without-go] `tuimark run` shows the document in your terminal and lets you use it: keys, focus, and the mouse all behave as they will in your app, and it reloads whenever you save. ```sh tuimark run app.tui --data sample.json --strip ``` With no host program, every action the document fires is listed in a strip at the bottom of the screen (`F12` shows or hides it). A `--mock` file gives each action fixed store writes, enough for a clickable prototype: an `add` that clears the input, a `cancel` that closes a modal. See [Running a document](/guide/run). To start a new project instead of writing the files by hand, `tuimark new NAME` writes a document, a stylesheet, sample data, and an `AGENTS.md` for coding agents, all of which validate cleanly. See [`new`](/guide/tools#new). ## Run it [#run-it] A host program loads the document, gives it data, and handles the actions it names. Nothing else: layout, focus, keys, colors, and the terminal stay in the runtime. ```go package main import ( "encoding/json" "fmt" "os" "github.com/abdul-hamid-achik/tuimark" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func run() error { ui, err := tuimark.Load("app.tui") if err != nil { return err } raw, err := os.ReadFile("sample.json") if err != nil { return err } var data map[string]any if err := json.Unmarshal(raw, &data); err != nil { return err } if err := ui.Bind("", data); err != nil { // the whole store return err } ui.On("open", func(ev tuimark.Event) error { return ui.Set("project", fmt.Sprintf("opened %v", ev.Keys["t"])) }) return ui.Run(os.Stdout) } ``` ```sh go mod init hello go get github.com/abdul-hamid-achik/tuimark@latest go run . ``` `q` quits: `quit` is a built-in action, so it needs no handler. `moved` has no handler either, so it does nothing. See the [Go API](/guide/go-api) for everything else a host can do. ## Where next [#where-next] - [Use Tuimark with AI agents](/guide/agents): the loop above, as an agent runs it. - [The .tui language](/guide/language), [TCSS](/guide/tcss), and [Layout](/guide/layout): what you can write. - [Widgets](/guide/widgets): lists, tables, tabs, inputs, modals, and the rest. - [The kit](/guide/kit): ready-made components to copy into your project. - [Examples](/examples): complete apps from the repository. --- # Use Tuimark with AI agents URL: https://tuimark.dev/guide/agents > How a coding agent such as Claude Code or Cursor builds terminal UIs with Tuimark - the AGENTS.md briefing from tuimark agents, the tuimark mcp server, the validate, dump and play loop, and the rules that keep it on track. Tuimark was designed for a coding agent to write. An agent is good at editing text and reading structured output, and cannot look at a terminal. So in Tuimark the UI is text, and every frame can be printed as JSON: the agent edits files, renders them, reads back exactly what is on the screen, and repeats. Setting an agent up takes two steps: give it the briefing, and give it the tools, either as shell commands or as an MCP server. ## The briefing: AGENTS.md [#the-briefing-agentsmd] `tuimark agents` prints a complete briefing for an agent: what to edit and what not to, the loop to follow, the tags and the attributes each takes, every CSS property and its values, the key tokens, every diagnostic code, and the behavior worth knowing. It is generated from the runtime's own catalogs, so it always matches the binary that printed it. Put it where your agent looks for instructions: ```sh tuimark agents --project > AGENTS.md ``` - **A new project** gets it for free: [`tuimark new`](/guide/tools#new) writes the same briefing as the project's `AGENTS.md`, next to a working `app.tui`, `theme.tcss`, and `sample.json`. - **Claude Code** reads `CLAUDE.md`. Import the briefing from it with a line that says `@AGENTS.md`, so both files stay one. - **Cursor, Codex, and most other agents** read `AGENTS.md` at the root of the repository directly. Regenerate it when you upgrade Tuimark. Without `--project`, `tuimark agents` prints the briefing as the Tuimark repository itself uses it, with paths into its source; use `--project` everywhere else. The [reference](/reference) pages on this site are generated from the same output. Agents and editors that need the vocabulary as data, not prose, read [`tuimark catalog`](/guide/tools#catalog). ## Connect the MCP server [#connect-the-mcp-server] An agent with a shell can run the loop below as commands. An agent that reaches tools through the Model Context Protocol gets the same loop from `tuimark mcp`: five read-only tools, `tuimark_validate`, `tuimark_dump`, `tuimark_play`, `tuimark_inspect`, and `tuimark_agents`, each returning exactly what the matching command prints. It needs only the `tuimark` binary on your `PATH`. Add it for yourself: ```sh claude mcp add tuimark -- tuimark mcp ``` or for everyone who works on the repository, which writes it to `.mcp.json` at the root: ```sh claude mcp add --scope project tuimark -- tuimark mcp ``` Add it to `.cursor/mcp.json` in the project (or `~/.cursor/mcp.json` for every project): ```json { "mcpServers": { "tuimark": { "command": "tuimark", "args": ["mcp"] } } } ``` Any MCP client that starts stdio servers takes the same two values: the command `tuimark` and the single argument `mcp`. Most of them read the `mcpServers` JSON shown for Cursor; see your client's documentation for where it goes. [MCP server](/guide/mcp) lists every tool's arguments and how to read a result. ## Docs for agents [#docs-for-agents] This site is written for agents too. Every page has a Markdown version at the same URL plus `.md` (this page is [`/guide/agents.md`](/guide/agents.md)), which a request with `Accept: text/markdown` also gets. [`/llms.txt`](/llms.txt) indexes them, and [`/llms-full.txt`](/llms-full.txt) is the whole site in one file, for an agent that should read everything first. ## The loop [#the-loop] The whole method fits in four commands: ```sh tuimark validate app.tui --data sample.json tuimark dump app.tui --data sample.json --cols 80 --rows 24 --format json tuimark dump app.tui --data sample.json --cols 120 --rows 24 --format json tuimark play app.tui --data sample.json --input "tab down enter" --styles --format json ``` 1. **Edit** the `.tui`, the `.tcss`, and the sample data. 2. **Validate.** Fix every error; read the warnings. Each diagnostic has a code, a position, and usually says what to write instead. 3. **Dump** at 80 and 120 columns (and 40 if it has to fit a narrow pane). Read `grid` for what the screen shows and `nodes` for where each element landed, and check that `errors` is empty. 4. **Play** the interactions: read `events` to confirm the right actions fired with the right `keys` and `value`, and `styles` to confirm that focus and selection actually change how cells look. Stop when the dumps match the intent at every width and nothing is reported. Then a person, or a host program, can run it for real. An agent connected to [the MCP server](#connect-the-mcp-server) runs the same loop as tool calls: `tuimark_validate`, `tuimark_dump`, and `tuimark_play` take the same arguments as the flags above and return what the commands print. ### Reading a dump [#reading-a-dump] | Question | Where to look | |---|---| | Does it render at all? | `ok` is `true` and `errors` is empty | | Is the text right? | `grid`, one string per row | | Is this panel the right size, in the right place? | the node's `x`, `y`, `w`, `h` in `nodes` | | Is the list scrolled to the selection? | the list's `scroll` and the `selected` item | | Does focus start in the right place? | `focus` | | Did a conditional class apply? | the node's `classes` | | Is the focused row highlighted? | `play … --styles`: the row's spans have `"a": ["reverse"]` | | Why is this cell this color? | `tuimark inspect app.tui --at X,Y` | ## Rules that keep an agent on track [#rules-that-keep-an-agent-on-track] The briefing states them, and they are worth repeating in your own instructions: - **Edit only view files**: `*.tui`, `*.tcss`, and sample data. The view never needs Go. - **Never invent a tag or a property.** The vocabulary is closed; an invented one is a diagnostic, not a feature. Check the [tags](/reference/tags) and [properties](/reference/css) instead. - **No logic in markup.** If the view needs a computed value, it goes in the data, and the host computes it. - **No terminal UI framework code.** No hand-written layout, widths, padding, or truncation in the host; Tuimark does all of that from the document. - **Name actions; do not implement them in markup.** `on:select="open"` is a name, and the host decides what it means. ## A prompt that works [#a-prompt-that-works] > Build the view for a deploy dashboard in `ui/app.tui` and `ui/theme.tcss`, with realistic data in `ui/sample.json`: a pipeline list on the left, a log panel on the right, a status bar, and a confirm modal for deploying. Read `AGENTS.md` first. Loop with `tuimark validate` and `tuimark dump --format json` at 80 and 120 columns until there are no diagnostics, and check the keymap with `tuimark play`. Do not write any Go. When you are done, list the action names the view fires. ## Handing off to the host [#handing-off-to-the-host] When the view is done, the host needs to know what to handle. `tuimark validate app.tui --json` includes the action catalog: every name, where it is used, and which are built in. Save the names the host implements to `actions.json`, and from then on: ```sh tuimark validate app.tui --catalog actions.json ``` reports any action the view fires that the host does not know (`B004`), which catches a renamed action on either side. In Go, `ui.Catalog()` gives the same list; a [test](/guide/testing#go-tests) can require a handler for every name. ## Keeping it honest [#keeping-it-honest] - Commit goldens for the sizes you checked ([testing](/guide/testing#golden-dumps)): the next agent that edits the view gets a failing test and a diff, not a surprise. - Keep `sample.json` realistic, with the long names, empty lists, and wide characters real data will have. --- # The .tui language URL: https://tuimark.dev/guide/language > How a Tuimark document is structured, the closed set of tags, common attributes, text, screens and modals, and document versions. A `.tui` file is a strict subset of XML that describes what is on the screen: containers, text, and widgets, with their data bindings and the names of the actions they fire. It holds no logic. How things look lives in [TCSS](/guide/tcss), the data in JSON, and what actions do in the host program. ## A document [#a-document] ```tui … … ``` - **``** is the root. It needs no `version` (see [versions](#versions)). `theme` is `dark` (the default), `light`, or `auto`. `mouse` turns mouse input on; see [the mouse](/guide/interaction#the-mouse). - **` 12 30% 1fr 2fr ``` ```grid ┌──────────┐┌────────────────┐┌────────┐┌──────────────────┐ │12 ││30% ││1fr ││2fr │ └──────────┘└────────────────┘└────────┘└──────────────────┘ ``` At 60 columns, `#a` takes 12 and `#b` takes 30% of 60, 18. The 30 cells left are shared one part to `#c` and two parts to `#d`. The last `fr` item gets any remainder, so no cell is lost to rounding. ## The box model [#the-box-model] `width` and `height` are the **outer** size, border included. Inside it: ``` outer the allocated rect: x, y, w, h border one cell on each side, when there is one padding inside the border content what is left for children or text ``` `padding` and `margin` take one to four cell values, in the CSS order (top, right, bottom, left). `gap` (0–4) puts empty cells between a container's children. A size that would leave a negative content box is clamped to zero, and anything that does not fit is clipped, never wrapped onto the next line. In `version="3"`, `row-gap` and `column-gap` (0–4) split `gap` into its two axes: `column-gap` between siblings side by side (a `layout: row` container, grid and table columns, a tab strip, a `hints` laid out along a row), `row-gap` between siblings stacked (a `layout: column` container, grid rows, a `hints` with `layout: column`). `gap` stays their shorthand in the cascade — it sets both, with its own specificity and order — so a document that writes only `gap` lays out exactly as before. ## Direction and alignment [#direction-and-alignment] `col` stacks its children vertically, `row` horizontally, and `box` is a column unless you set `layout: row`. Any container takes `layout: column | row` (and, in version 2, `grid`). | Property | Axis | Values | |---|---|---| | `justify` | main | `start`, `center`, `end`, `space-between` | | `align` | cross | `stretch` (the default), `start`, `center`, `end` | | `content-align` | text inside its box | `start`, `center`, `end` | With the default `align: stretch`, children fill the cross axis. That is usually what you want, but it is also why a row of buttons in a tall modal grows tall: give such a row `height: 1`. ## Default sizes [#default-sizes] | Tag | Width | Height | |---|---|---| | `screen` | 100% | 100% | | `col`, `row` | `1fr` | `1fr` | | `box` in a `row` | `auto` | `1fr` | | `box` in a `col` | `1fr` | `auto` | | `text`, `input`, `button`, `progress`, `rule` | `auto` | `auto` | | `list`, `scroll`, `table` in a `col` | `1fr` | `auto`, fit to the space left | | `sparkline` | one column per value | 1 | | `modal` | 80% of the screen | 80% of the screen | If any sibling on an axis has an `fr` size, siblings with no size on that axis become `1fr` too. ## How space is shared [#how-space-is-shared] Along the main axis, a container hands out its content size in this order: 1. fixed sizes (`N`); 2. percentages, of the parent's full content size (not of what is left); 3. `auto` children, at their content size; 4. viewports sized `auto`, at their content size but capped by what is left ([auto-fit](#auto-fit)); 5. what remains, split between the `fr` children by weight. `min-width`, `max-width`, `min-height`, and `max-height` clamp a child after its percentage and before the `fr` split. There is no flex-wrap: a row that does not fit is clipped. ## Docks [#docks] `dock: top | bottom | left | right` pulls a child out of the flow and pins it to an edge of its parent. Top and bottom strips are cut first, then left and right, and the other children share what is left. ```tui header · dock: top status · dock: bottom dock: left everything else ``` ```grid header · dock: top ┌────────────┐┌────────────────────────────────┐ │dock: left ││everything else │ │ ││ │ │ ││ │ │ ││ │ └────────────┘└────────────────────────────────┘ status · dock: bottom ``` Give a docked child a size across its edge: a `height` for `top` and `bottom`, a `width` for `left` and `right`. An `fr` size on the same axis as a dock is `V010`, and more than one bottom-docked status line is warned about (`L005`). ## Scrolling [#scrolling] A **viewport** clips its content and scrolls it: a `scroll`, a `list`, a `table`, or any container with `overflow: scroll`. - `` (the default) scrolls vertically, `axis="x"` horizontally, `axis="both"` both ways. `list`, `table`, and `overflow: scroll` scroll on `y`. - A `scroll` takes the arrow and paging keys when it has focus; give it `focusable="true"` and an `id`. A list or table follows its cursor instead. - In version 2, `scrollbar: auto` paints a thumb over the right border of a bordered viewport at least 3 rows tall whose content does not fit. It never takes layout space. - In `version="3"`, `` keeps a growing viewport showing its end, like a log or a chat: while the offset is already at the bottom it keeps sticking as content arrives, but scrolling up to read back stops it from jumping, until you scroll (or `move-last`) back to the end. It needs an `id` and a `y` axis, and cannot go inside a list `` or a container `each` template. The dump reports each viewport's `scroll` state (offset and extent), and a list reports every item, including the ones scrolled out of view. ## Auto-fit [#auto-fit] A viewport sized `auto` on its scroll axis takes its content size, capped by the space its siblings leave. A short list stays short; a long one fills what is left and scrolls, and whatever comes after it stays on screen. ```tui deploy log {l.n} {l.text} {count} lines ``` ```grid deploy log ┌──────────────────────────────┐ │9 rotate secrets │ │10 warm caches │ │11 notify #deploys │ │12 done │ └──────────────────────────────┘ 12 lines ``` Twelve lines of log do not fit in 8 rows, so the list takes the 6 rows the title and the status line leave, and scrolls to keep its cursor (the last line) in view. Viewports are served in document order: the first may leave nothing for the next. To share space between two viewports, give both an `fr` size. `L006` warns when a viewport can never show any of its content: zero rows while it has some, or clipped away by an ancestor that does not scroll. The fix is a size (`N`, `%`, or `fr`) or more room in the parent. ## Grids [#grids] In version 2, `layout: grid` places a container's children in equal columns, row by row, which is what a dashboard of panels needs: ```tui 23% 61% 73% 1.20 2.1 MB/s 52 C ``` `grid-columns: K` is the most columns (1 to 12). With `grid-min-width: M`, the count adapts to the width: as many columns of at least `M` cells as fit, up to `K`, so the grid is responsive without any `@media`. The same document at 64 and 36 columns:
```grid ╭─ cpu ────────────╮ ╭─ memory ─────────╮ ╭─ disk ─────────────╮ │23% │ │61% │ │73% │ ╰──────────────────╯ ╰──────────────────╯ ╰────────────────────╯ ╭─ load ───────────╮ ╭─ net ────────────╮ ╭─ temp ─────────────╮ │1.20 │ │2.1 MB/s │ │52 C │ ╰──────────────────╯ ╰──────────────────╯ ╰────────────────────╯ ``` ```grid ╭─ cpu ─────────╮ ╭─ memory ───────╮ │23% │ │61% │ ╰───────────────╯ ╰────────────────╯ ╭─ disk ────────╮ ╭─ load ─────────╮ │73% │ │1.20 │ ╰───────────────╯ ╰────────────────╯ ╭─ net ─────────╮ ╭─ temp ─────────╮ │2.1 MB/s │ │52 C │ ╰───────────────╯ ╰────────────────╯ ```
`gap` separates both columns and rows. A row is as tall as its tallest child, and `align` places shorter ones. A child's `width`, `min-width`, `max-width`, `flex`, and any `fr` or `%` height are ignored in a grid (`L007` warns). A grid never shrinks to fit: put it in a `` (or make it `overflow: scroll`) to scroll its rows when they do not fit. ## Responsive layouts [#responsive-layouts] `@media` switches rules by terminal size. This is the tour document from the [home page](/): at 56 columns its panes stack. ```tcss #body { gap: 1; } #tickets { width: 45%; border: rounded; border-color: $border; } #tickets:focus { border-color: $focus; } #detail { width: 1fr; border: rounded; border-color: $border; } #keys { height: 1; } .hint-key { color: $accent; bold: true; } @media (max-cols: 60) { #body { layout: column; gap: 0; } #tickets { width: 100%; height: 5; } #detail { width: 100%; height: 1fr; } } ``` ```grid ╭─ tickets ────────────────────────────────────────────╮ │t-1 login loop on staging │ │t-2 cannot deploy europe-west │ │t-3 stale feature flag cache │ ╰──────────────────────────────────────────────────────╯ ╭─ t-2 ────────────────────────────────────────────────╮ │Deploys to europe-west fail at the health check: the │ │new probe path returns 404. │ │ │ │ │ │ │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────╯ q quit enter open ``` `display: none` removes a node from the layout entirely (the monitor example hides table columns that way on narrow terminals); `visibility: hidden` keeps its space and paints nothing. ## Checking a layout [#checking-a-layout] Dump at the widths you care about and read the nodes: ```sh tuimark dump app.tui --data sample.json --cols 80 --rows 24 tuimark dump app.tui --data sample.json --cols 120 --rows 24 --format json ``` Layout problems are diagnostics with an `L` code. The ones you will meet: | Code | Meaning | |---|---| | `L001` | an `fr` size or `flex` on a child along its viewport's scroll axis, where there is no leftover to share | | `L002` | a `%` size inside a parent whose size on that axis is `auto` | | `L003` | fixed and minimum sizes that add up to more than the parent (a warning; the rest is clipped) | | `L004` | a modal that is not the last child of its screen | | `L006` | a viewport that can never show its content | | `L007` | a size on a grid cell, which the grid ignores | The full list is in the [diagnostics reference](/reference/diagnostics#layout). --- # Bindings and data URL: https://tuimark.dev/guide/bindings > How Tuimark markup reads a JSON store - {path} interpolation, bind, each, if, truthiness and class:NAME guards - and why it has no expressions. A Tuimark app has one data store: a JSON value, usually an object. The host fills it with `Bind` and changes it with `Set`; the CLI loads it from a file with `--data sample.json`. Markup reads it through **paths** and never computes anything: every value it shows is already in the store. ## Paths [#paths] A path is a dotted chain of names: `title`, `ticket.body`, `cpu.hist`. Each name is a letter or `_` followed by letters, digits, or `_` (no hyphens, no spaces, no indexes). Inside an `each` template, the alias comes first: in `each="tickets as t"`, `t.title` is the current ticket's title. Arrays are reached only through `each`; `tickets.0.title` is not a path markup can use. ## Interpolation [#interpolation] `{path}` inserts a value into text. It works in four places: the body of a ``, a `title`, a `placeholder`, and the body of a `` (the cell template of a [table](/guide/widgets#table)). ```tui {done} of {total} done ({done}) … ``` The value is formatted as text: | Value | Shows as | |---|---| | a string | itself, with control characters and ANSI escapes removed | | an integer-valued number | `3`, `1024` | | another number | the shortest form that reads back the same: `2.5`, `1e+20` | | `true`, `false` | `true`, `false` | | `null` | nothing | | an array or object | compact JSON, object members sorted: `[1,"a"]`, `{"a":2,"b":1}` | A path that does not resolve shows nothing and reports `B003`, a warning (`--strict` makes it an error). Anything between braces that is not a plain path, such as `{ folder }` or `{a + b}`, is `V003`. A `{` with no closing `}` is just text. ## Widgets that write back [#widgets-that-write-back] `bind` connects a widget's own state to a path, in both directions: | Widget | `bind` holds | Written when | |---|---|---| | `input` | its text | the user types | | `list`, `table` | the key of the cursor row | the cursor moves | | `tabs` | the id of the active tab | a tab is activated | | `progress` | a number from 0 to 100 | never (read only) | | `sparkline` | an array of numbers | never (read only) | | `modal` | truthy while open | never (read only) | So the host never has to track what is typed or selected: it is in the store, and the `on:change` or `on:select` event says when it moved. Setting the path from the host moves the widget. ## Repeating with each [#repeating-with-each] `each="path as alias"` repeats a template once per element of an array, in order. On a `list` (and in version 2 a `table`), the template is the `` and each element is a row: ```tui {t.id} {t.title} ``` - `key="path"` names each row. The cursor, `bind`, checked rows, and the `keys` of every event use it, so a row keeps its identity when the array is re-sorted or filtered. Without `key`, rows are numbered by index and you get `B006`. - The path must be an array: a missing or non-array value is `B001`. - Nothing inside a list row can take focus. The list is the focusable unit and moves its own cursor; act on a row through `on:select` or a keymap row with `when="#tickets:focus"`, whose event names the row. In version 2, `each` also works on `col`, `row`, and `box`. The container is not repeated; its children are the template, inflated once per element: ```tui {tag} ``` Such a template holds nothing that takes focus or keeps state (no `input`, `button`, `list`, `modal`, `table`, `tabs`, `tab`, no `focusable`, `on:click`, or `on:focus`) and no `id`, since it would repeat. An alias that shadows an enclosing one is `V011`. An empty array leaves the container empty, so it matches `:empty`. ## Conditions [#conditions] `if="path"` keeps a node only while the value is truthy; `if="!path"` only while it is not: ```tui {selected_ticket.title} no selection ``` | Value | Truthy? | |---|---| | `null`, `false`, `0`, `""`, `[]` | no | | everything else, `{}` included | yes | A path that does not resolve reports `B002` and counts as `null`: `if="missing"` removes its node and `if="!missing"` keeps it. `hidden` and `disabled` take the same `path` and `!path`, or a literal `true` or `false`. A hidden node is not laid out; a disabled one stays visible but cannot take focus, and keys, actions, and clicks skip it. A modal's `open` and the document's `mouse` flag work the same way. ## Conditional classes [#conditional-classes] In version 2, `class:NAME="path"` adds the class `NAME` while the path is truthy (or `class:NAME="!path"` while it is not). It is how data changes the look of a node without any expression in markup: ```tui {status_label} ``` ```tcss .live { color: $ok; } .stale { color: $danger; } ``` The guard is evaluated on every frame, in the node's scope, so inside a template it reads the alias: `` colors the rows of protected processes. `NAME` is lowercase: a letter or `_`, then letters, digits, `_`, or `-` (`V015` otherwise). A missing path is `B002` and adds nothing. The JSON dump of a version 2 document lists each node's `classes`, the static ones first, so you can check that a guard fired without reading colors. ## No expressions [#no-expressions] Markup cannot compute: no arithmetic, comparisons, string formatting, or filtering. When a view needs a derived value (a count, a label like `cpu 23%`, a flag like `cpu_hot`, the filtered rows), the host computes it and puts it in the store. This keeps every `.tui` file readable at a glance, keeps the runtime small, and gives an agent exactly one place to look when a value is wrong: the data. ```json { "count": 3, "cpu": { "label": "cpu 23%", "pct": 23, "hot": false }, "tickets": [{ "id": "t-12", "title": "login loop on staging" }] } ``` Keep a `sample.json` next to each document with realistic data, including the awkward cases (long strings, empty arrays, wide characters). The CLI renders with it, `validate --data` checks every path against it, and golden tests pin it. --- # Keymap, actions and focus URL: https://tuimark.dev/guide/interaction > How keys, clicks and focus become named actions in Tuimark - the keymap, when selectors over the focus chain, built-in actions, key dispatch, event payloads and the mouse. Markup never runs code. When something happens (a key, a click, a cursor move, a submitted input), the runtime fires an **action**: a name, with a small payload that says where it came from. The host program registers handlers for the names it cares about. A few names are built in. ## Actions are names [#actions-are-names] Widgets name actions in `on:*` attributes: ```tui … ``` The label comes from `label` or the body, painted as `[ Deploy ]`. A focused button fires `on:click` on `enter` or `space`, and on a mouse click. Show focus with `button:focus { reverse: true; }`. ## progress and sparkline [#progress-and-sparkline] A `progress` is a bar from 0 to 100, from `bind` or a literal `value`. A `sparkline` (version 2) draws the last values of a numeric array as bars, one column per value and eight levels per row. ```tui block eighths value ``` - `bar: eighths` (version 2) fills eighths of a cell for a smoother bar; `bar: block`, the default, fills whole cells. - A sparkline is naturally one column per value and one row tall; give it a `height` for more levels. Values are right-aligned in the width it gets, `null` is a gap, and `min` and `max` fix the range (otherwise the shown values set it). - The bar color is the widget's `color`. - **`scale="NAME"`** (`version="3"`) shares a range across every sparkline of one frame that carries the same name: they all take `lo`/`hi` from the smallest and largest of their shown values together, so a group of related gauges reads on one scale. A sparkline's own `min`/`max` still wins for that one; an inactive tab's sparkline is not in the group. ## table [#table] Rows of an array, in columns, with a cursor (version 2). Each `` has a header `title` and a body that is the cell template. ```tui {p.pid}{p.name}{p.cpu}{p.mem}
``` - It keeps a cursor like a list: `bind` holds the cursor row's key, `on:select` fires with the row in `keys`, and a focused table takes the arrow and paging keys. `checked`, `mark`, and the `check-*` built-ins work as on a list. - **Column widths**: a number of cells; a `%` of the table; an `fr` share of what is left, clamped by `min-width` and `max-width`; or, by default, the widest of the title and every cell. - **Styling**: a rule on a column styles its header cell. Body cells carry the column's `class` and its `class:NAME` guards, evaluated per row, so `.num { content-align: end; }` right-aligns a whole column and `#procs > item > text.hot` colors only the hot cells. One attribute-only `` styles whole rows. - Only the rows in view are laid out, painted, and dumped, so a table of thousands of rows costs what its visible rows cost. - Cells never wrap. `placeholder` shows on the first row when the array is empty. - `display: none` on a column hides it. Under `@media`, that is how a table drops columns on a narrow terminal. The same document at 30 columns: ```grid ╭────────────────────────────╮ │ PID NAME CPU%│ │ 412 WindowServer 87.3│ │ 8810 Code Helper 41.8│ │ 9921 node 18.2│ │ 15023 go 9.7│ ╰────────────────────────────╯ ``` A `column` with `on:click` fires when its header is clicked, the natural place for sorting. - **`priority="N"`** (`version="3"`) hides a column automatically when the visible columns no longer fit: the column with the smallest `N` hides first (a non-negative integer; the last one in document order breaks a tie), and it comes back once the table is wide enough again. A hidden column is exactly a `display: none` one — no header cell, no body cells — but it still counts toward the table's intrinsic width. Prefer `priority` to `@media` rules for column hiding; a column without `priority` never hides this way. ## tabs [#tabs] A strip of tab labels over the content of the active tab (version 2). ```tui panels the process table settings ``` - With `bind`, the active tab is the one whose id is the bound string, and activating a tab writes its id there; `on:select` fires with the id as the value. Without `bind`, the runtime remembers the tab. - Tabs switch with the `switch-to`, `move-next`, and `move-prev` built-ins, with `left` and `right` on a `focusable="true"` tabs, with a click on a label, and when focus moves into an inactive tab. - Only the active tab is laid out; widgets in the others keep their state (a list's cursor, an input's text). - When a tab becomes active while focus was in the old tab, on the strip, or nowhere, focus moves to the new tab's `focus="#id"` target, or its first focusable node. - **Labels always fit.** Every `label` when there is room, else every `short`, else only the active tab as `‹ label ›`. The same strip at 24 and 16 columns:
```grid 1 ovr ▸2 proc 3 cfg the process table ``` ```grid ▸‹ 2 processes › the process tabl ```
Labels are generated `text` nodes with the class `tab-label`, `:selected` on the active one; style them with `.tab-label`. In `version="3"`, `label` and `short` are templates, resolved every frame exactly like a `` body (`{path}`, no expressions), so a tab can show a live count: ``. A missing path resolves to empty, and any line break in the resolved value becomes a space, since a label is one row. In `version="1"`/`version="2"`, `{path}` in either attribute stays `V003`. ## hints [#hints] Key hints generated from the keymap (version 2). Every `` row with a `label` is a hint; `keycap` replaces the key text shown. ```tui {f.path} ``` By default (`scope="active"`), a hint shows only while one of its keys would actually fire, through the same key handling the app uses. Press `/` and focus moves to the input, which takes `q`, `/`, `space`, and `ctrl+a` for itself, so only the hint that still applies is left: ```grid / to filter ╭──────────────────────────────────────────╮ │cmd/app/main.go │ │internal/app.go │ │go.mod │ │README.md │ ╰──────────────────────────────────────────╯ esc done ``` `scope="all"` lists every hint, for a help screen. Items are laid out along the row (or down it with `layout: column`), and the ones that do not fit are dropped from the end. Style them with `.hint-key` and `.hint-label`. ## modal [#modal] A layer over the screen, open while `open` is truthy. ```tui the files you marked This cannot be undone. ``` The label comes from `label` or the body, painted as `[ Deploy ]`. A focused button fires `on:click` on `enter` or `space`, and on a mouse click. Show focus with `button:focus { reverse: true; }`. ## progress and sparkline [#progress-and-sparkline] A `progress` is a bar from 0 to 100, from `bind` or a literal `value`. A `sparkline` (version 2) draws the last values of a numeric array as bars, one column per value and eight levels per row. ```tui block eighths value ``` - `bar: eighths` (version 2) fills eighths of a cell for a smoother bar; `bar: block`, the default, fills whole cells. - A sparkline is naturally one column per value and one row tall; give it a `height` for more levels. Values are right-aligned in the width it gets, `null` is a gap, and `min` and `max` fix the range (otherwise the shown values set it). - The bar color is the widget's `color`. - **`scale="NAME"`** (`version="3"`) shares a range across every sparkline of one frame that carries the same name: they all take `lo`/`hi` from the smallest and largest of their shown values together, so a group of related gauges reads on one scale. A sparkline's own `min`/`max` still wins for that one; an inactive tab's sparkline is not in the group. ## table [#table] Rows of an array, in columns, with a cursor (version 2). Each `` has a header `title` and a body that is the cell template. ```tui {p.pid}{p.name}{p.cpu}{p.mem}
``` - It keeps a cursor like a list: `bind` holds the cursor row's key, `on:select` fires with the row in `keys`, and a focused table takes the arrow and paging keys. `checked`, `mark`, and the `check-*` built-ins work as on a list. - **Column widths**: a number of cells; a `%` of the table; an `fr` share of what is left, clamped by `min-width` and `max-width`; or, by default, the widest of the title and every cell. - **Styling**: a rule on a column styles its header cell. Body cells carry the column's `class` and its `class:NAME` guards, evaluated per row, so `.num { content-align: end; }` right-aligns a whole column and `#procs > item > text.hot` colors only the hot cells. One attribute-only `` styles whole rows. - Only the rows in view are laid out, painted, and dumped, so a table of thousands of rows costs what its visible rows cost. - Cells never wrap. `placeholder` shows on the first row when the array is empty. - `display: none` on a column hides it. Under `@media`, that is how a table drops columns on a narrow terminal. The same document at 30 columns: ```grid ╭────────────────────────────╮ │ PID NAME CPU%│ │ 412 WindowServer 87.3│ │ 8810 Code Helper 41.8│ │ 9921 node 18.2│ │ 15023 go 9.7│ ╰────────────────────────────╯ ``` A `column` with `on:click` fires when its header is clicked, the natural place for sorting. - **`priority="N"`** (`version="3"`) hides a column automatically when the visible columns no longer fit: the column with the smallest `N` hides first (a non-negative integer; the last one in document order breaks a tie), and it comes back once the table is wide enough again. A hidden column is exactly a `display: none` one — no header cell, no body cells — but it still counts toward the table's intrinsic width. Prefer `priority` to `@media` rules for column hiding; a column without `priority` never hides this way. ## tabs [#tabs] A strip of tab labels over the content of the active tab (version 2). ```tui panels the process table settings ``` - With `bind`, the active tab is the one whose id is the bound string, and activating a tab writes its id there; `on:select` fires with the id as the value. Without `bind`, the runtime remembers the tab. - Tabs switch with the `switch-to`, `move-next`, and `move-prev` built-ins, with `left` and `right` on a `focusable="true"` tabs, with a click on a label, and when focus moves into an inactive tab. - Only the active tab is laid out; widgets in the others keep their state (a list's cursor, an input's text). - When a tab becomes active while focus was in the old tab, on the strip, or nowhere, focus moves to the new tab's `focus="#id"` target, or its first focusable node. - **Labels always fit.** Every `label` when there is room, else every `short`, else only the active tab as `‹ label ›`. The same strip at 24 and 16 columns:
```grid 1 ovr ▸2 proc 3 cfg the process table ``` ```grid ▸‹ 2 processes › the process tabl ```
Labels are generated `text` nodes with the class `tab-label`, `:selected` on the active one; style them with `.tab-label`. In `version="3"`, `label` and `short` are templates, resolved every frame exactly like a `` body (`{path}`, no expressions), so a tab can show a live count: ``. A missing path resolves to empty, and any line break in the resolved value becomes a space, since a label is one row. In `version="1"`/`version="2"`, `{path}` in either attribute stays `V003`. ## hints [#hints] Key hints generated from the keymap (version 2). Every `` row with a `label` is a hint; `keycap` replaces the key text shown. ```tui {f.path} ``` By default (`scope="active"`), a hint shows only while one of its keys would actually fire, through the same key handling the app uses. Press `/` and focus moves to the input, which takes `q`, `/`, `space`, and `ctrl+a` for itself, so only the hint that still applies is left: ```grid / to filter ╭──────────────────────────────────────────╮ │cmd/app/main.go │ │internal/app.go │ │go.mod │ │README.md │ ╰──────────────────────────────────────────╯ esc done ``` `scope="all"` lists every hint, for a help screen. Items are laid out along the row (or down it with `layout: column`), and the ones that do not fit are dropped from the end. Style them with `.hint-key` and `.hint-label`. ## modal [#modal] A layer over the screen, open while `open` is truthy. ```tui the files you marked This cannot be undone.