Skip to content

Examples

Every file in examples/, embedded from the repository. From a clone, run one with task example NAME=<name> (or tui-builder examples/<name>.yaml), or browse and run them all with task examples, which opens example-launcher.

Each file is commented and meant to be copied and adapted. Every one is loaded by the test suite, so they stay valid as the schema changes.

Components

list

Filterable list and navigation keys.

task example NAME=list
list.yaml
# list.yaml — a single filterable list.
#
# Keys when focused:
#   j / ↓ / k / ↑    move cursor
#   g / G            top / bottom
#   ctrl+u / ctrl+d  half page
#   /                engage filter (esc to clear)
#   ← / → / h / l    horizontal scroll on long items
app:
  title: List Demo
  theme: nord

tui:
  components:
    fruits:
      type: list
      title: Fruits
      filterable: true
      items:
        - Apple
        - Apricot
        - Banana
        - Blueberry
        - Cherry
        - Date
        - Elderberry
        - Fig
        - Grape
        - Honeydew
        - Kiwi
        - Lemon
        - Mango
        - Nectarine
        - Orange
        - Papaya
        - Quince
        - Raspberry
        - Strawberry
        - Tangerine
  screen:
    title: Fruits
    layout:
      component: fruits

table

Filterable, sortable table and the filter syntax (key:value, ~regex).

task example NAME=table
table.yaml
# table.yaml — single sortable + filterable table.
#
# Keys when focused:
#   j/↓ k/↑          row cursor
#   g / G            top / bottom
#   ctrl+u / ctrl+d  half page
#   ← / →            horizontal scroll
#   shift+← / shift+→  jump column-by-column
#   /                engage filter
#   [ / ]            step active sort column among Sortable
#   s                toggle sort direction (▲ / ▼)
#
# Filter syntax (when /):
#   term             case-insensitive substring across all cells
#   region:europe    scope to column whose title starts with "region"
#   ~^new            regex term (case-insensitive)
#   region:~^euro    regex scoped to one column
#   a b c            space-separated AND-ed
app:
  title: Table Demo
  theme: nord

tui:
  components:
    cities:
      type: table
      title: World Cities
      filterable: true
      columns:
        - {title: City,    width: 18, sortable: true}
        - {title: Country, width: 16, sortable: true}
        - {title: Region,  width: 12, sortable: true}
        - {title: Pop,     width: 10, sortable: true, sort: si, align: right}
      rows:
        - [Tokyo,        Japan,         Asia,     37M]
        - [Delhi,        India,         Asia,     32M]
        - [Shanghai,     China,         Asia,     28M]
        - [São Paulo,    Brazil,        Americas, 22M]
        - [Mexico City,  Mexico,        Americas, 22M]
        - [Cairo,        Egypt,         Africa,   21M]
        - [Mumbai,       India,         Asia,     20M]
        - [Beijing,      China,         Asia,     20M]
        - [Dhaka,        Bangladesh,    Asia,     20M]
        - [Osaka,        Japan,         Asia,     19M]
        - [New York,     USA,           Americas, 18M]
        - [Lagos,        Nigeria,       Africa,   16M]
        - [Buenos Aires, Argentina,     Americas, 15M]
        - [Kolkata,      India,         Asia,     15M]
        - [Manila,       Philippines,   Asia,     14M]
        - [Istanbul,     Turkey,        Europe,   15M]
        - [Moscow,       Russia,        Europe,   12M]
        - [Paris,        France,        Europe,   11M]
        - [London,       UK,            Europe,   9M]
        - [Reykjavík,    Iceland,       Europe,   130K]
  screen:
    title: Cities
    layout:
      component: cities

table_columns

Column sizing (fixed, auto, flex, max_width) and alignment.

task example NAME=table_columns
table_columns.yaml
# table_columns.yaml — column sizing modes.
#
# Width sizing per column:
#   width: N, flex: 0          fixed visible-cell width
#   width: 0, flex: 0          content-auto (max of title + widest cell, floor 4)
#   flex: K                    expand to absorb a share of leftover space,
#                              weighted by K. `width` becomes a minimum;
#                              `max_width` caps growth.
#
# Align: left (default), right, center.
app:
  title: Column Sizing
  theme: nord

tui:
  components:
    sizing:
      type: table
      title: Sizing modes
      columns:
        # fixed 6, right-aligned — typical for ID columns
        - {title: ID,       width: 6,  align: right}
        # content-auto — sized to whichever is widest, title or any cell
        - {title: Code}
        # flex 1, min 12 — grows to fill leftover space
        - {title: Name,     width: 12, flex: 1}
        # flex 2, min 8, capped at 40 — grows twice as much as Name
        - {title: Summary,  width: 8,  flex: 2, max_width: 40}
        # fixed 10, centered — typical for short statuses
        - {title: Status,   width: 10, align: center}
      rows:
        - [1,    A,   Alpha,   First entry in the dataset for demo purposes,                    OK]
        - [2,    BB,  Beta,    Second; demonstrates max_width capping a flex column at 40 cells, OK]
        - [3,    CCC, Gamma,   Short,                                                            WARN]
        - [42,   D,   Delta,   The fourth — IDs render right-aligned for numeric scanning,        ERROR]
        - [9999, EEE, Epsilon, Last,                                                              OK]
  screen:
    title: Column sizing
    layout:
      component: sizing

table_styled

Colored cells, clickable hyperlinks and initial_sort.

task example NAME=table_styled
table_styled.yaml
# table_styled.yaml — colored status cells + clickable URL cells.
#
# Cells are usually plain strings, but each cell can also be:
#   {value: "ERROR", color: red}          → ansi.CellColor (named or 0-255)
#   {label: "open", url: "https://…"}     → ansi.Hyperlink (shift/cmd-click)
#
# Named colors: black, red, green, yellow, blue, magenta, cyan, white,
# gray (alias bright_black), bright_*. Or pass an integer 0-255 for any
# 256-color palette index.
app:
  title: Styled Cells
  theme: nord

tui:
  components:
    jobs:
      type: table
      title: Jobs
      filterable: true
      initial_sort: {column: When, desc: true}
      columns:
        - {title: ID,     width: 6,  sortable: true, sort: number, align: right}
        - {title: Job,    width: 22, sortable: true}
        - {title: Status, width: 12, sortable: true, align: center}
        - {title: When,   width: 12, sortable: true, sort: si, align: right}
        - {title: Link,   width: 14}
      rows:
        - [1, "build-frontend",  {value: PASS,    color: green},       "2m",  {label: logs, url: "https://ci.example.com/1"}]
        - [2, "build-backend",   {value: PASS,    color: green},       "3m",  {label: logs, url: "https://ci.example.com/2"}]
        - [3, "lint",            {value: WARN,    color: yellow},      "8m",  {label: logs, url: "https://ci.example.com/3"}]
        - [4, "test-integration", {value: FAIL,    color: red},        "15m", {label: logs, url: "https://ci.example.com/4"}]
        - [5, "deploy-staging",  {value: SKIPPED, color: gray},        "30m", {label: logs, url: "https://ci.example.com/5"}]
        - [6, "smoke-test",      {value: PASS,    color: bright_green}, "1h", {label: logs, url: "https://ci.example.com/6"}]
        - [7, "rollback",        {value: ERROR,   color: bright_red},   "2h", {label: logs, url: "https://ci.example.com/7"}]
  screen:
    title: Pipeline
    layout:
      component: jobs

table_wide

A wide table with horizontal scroll (←/→, 0/$).

task example NAME=table_wide
table_wide.yaml
# table_wide.yaml — a table wider than the terminal, to exercise the
# table's horizontal scroll.
#
# Keys when focused (in addition to the usual j/k, g/G, /, [/]/s):
#   ← / → / h / l        scroll by HScrollStep cells
#   shift+← / shift+→    snap viewport to previous / next column boundary
#   0 / home             jump to the leftmost edge
#   $ / end              jump to the rightmost edge
#
# The thin bottom row inside the pane is the horizontal scrollbar
# (theme.Table() enables it by default).
app:
  title: Wide Table
  theme: nord

tui:
  components:
    deploys:
      type: table
      title: Deployments
      filterable: true
      initial_sort: {column: Updated, desc: true}
      columns:
        - {title: ID,         width: 8,  sortable: true, sort: number, align: right}
        - {title: Service,    width: 20, sortable: true}
        - {title: Cluster,    width: 18, sortable: true}
        - {title: Namespace,  width: 16, sortable: true}
        - {title: Image,      width: 30, sortable: true}
        - {title: Commit,     width: 12}
        - {title: Replicas,   width: 10, sortable: true, sort: number, align: right}
        - {title: CPU,        width: 8,  sortable: true, sort: si,     align: right}
        - {title: Memory,     width: 10, sortable: true, sort: si,     align: right}
        - {title: Health,     width: 10, align: center}
        - {title: Updated,    width: 12, sortable: true, sort: si,     align: right}
        - {title: Link,       width: 16}
      rows:
        - [101, frontend-web,         gke-prod-us,    web,          "ghcr.io/acme/frontend:1.42",   4f8c2a1, 8,  "1.2",  "512Mi", {value: HEALTHY,    color: green},  "2m",  {label: console, url: "https://console.example.com/d/101"}]
        - [102, frontend-mobile,      gke-prod-us,    web,          "ghcr.io/acme/mobile:1.42",     4f8c2a1, 3,  "600m", "256Mi", {value: HEALTHY,    color: green},  "2m",  {label: console, url: "https://console.example.com/d/102"}]
        - [103, api-gateway,          gke-prod-us,    edge,         "ghcr.io/acme/gateway:3.0.7",   9a1b3e4, 12, "2.4",  "1Gi",   {value: HEALTHY,    color: green},  "12m", {label: console, url: "https://console.example.com/d/103"}]
        - [104, auth-service,         gke-prod-eu,    edge,         "ghcr.io/acme/auth:2.11.3",     c2f0d8a, 6,  "1.0",  "512Mi", {value: DEGRADED,   color: yellow}, "1h",  {label: console, url: "https://console.example.com/d/104"}]
        - [105, search-indexer,       gke-prod-eu,    backend,      "ghcr.io/acme/indexer:0.18.2",  8e5a9c3, 4,  "800m", "2Gi",   {value: HEALTHY,    color: green},  "30m", {label: console, url: "https://console.example.com/d/105"}]
        - [106, recommendations,      gke-prod-eu,    backend,      "ghcr.io/acme/recs:5.4.0",      71d3e0b, 8,  "1.5",  "3Gi",   {value: HEALTHY,    color: green},  "3h",  {label: console, url: "https://console.example.com/d/106"}]
        - [107, billing-worker,       gke-prod-us,    backend,      "ghcr.io/acme/billing:11.0.0",  ee92f3a, 2,  "400m", "256Mi", {value: UNHEALTHY,  color: red},    "4h",  {label: console, url: "https://console.example.com/d/107"}]
        - [108, billing-api,          gke-prod-us,    backend,      "ghcr.io/acme/billing-api:11.0.0", ee92f3a, 4, "1.0", "512Mi", {value: DEGRADED, color: yellow}, "4h",  {label: console, url: "https://console.example.com/d/108"}]
        - [109, metrics-aggregator,   gke-prod-eu,    observability,"ghcr.io/acme/metrics:3.2.1",   12a8c4d, 5,  "1.2",  "1Gi",   {value: HEALTHY,    color: green},  "1d",  {label: console, url: "https://console.example.com/d/109"}]
        - [110, log-shipper,          gke-prod-us,    observability,"ghcr.io/acme/shipper:2.0.5",   12a8c4d, 6,  "300m", "128Mi", {value: HEALTHY,    color: green},  "1d",  {label: console, url: "https://console.example.com/d/110"}]
        - [111, alert-router,         gke-prod-eu,    observability,"ghcr.io/acme/alerts:1.7.0",    aa44b1f, 2,  "200m", "128Mi", {value: HEALTHY,    color: green},  "2d",  {label: console, url: "https://console.example.com/d/111"}]
        - [112, ml-feature-serving,   gke-prod-us,    ml,           "ghcr.io/acme/features:0.9.4",  77b9e6c, 10, "3.5",  "8Gi",   {value: HEALTHY,    color: green},  "5h",  {label: console, url: "https://console.example.com/d/112"}]
        - [113, ml-training,          gke-prod-eu,    ml,           "ghcr.io/acme/training:0.9.4",  77b9e6c, 1,  "8.0",  "32Gi",  {value: PENDING,    color: gray},   "6h",  {label: console, url: "https://console.example.com/d/113"}]
        - [114, payments-processor,   gke-prod-us,    payments,     "ghcr.io/acme/payments:6.3.1",  d8c1a2b, 6,  "1.8",  "768Mi", {value: HEALTHY,    color: green},  "10m", {label: console, url: "https://console.example.com/d/114"}]
        - [115, payments-reconciler,  gke-prod-us,    payments,     "ghcr.io/acme/recon:6.3.1",     d8c1a2b, 2,  "500m", "256Mi", {value: HEALTHY,    color: green},  "10m", {label: console, url: "https://console.example.com/d/115"}]
  screen:
    title: Deployments
    layout:
      component: deploys

logview

A log pane with / search, filter mode and initial_query.

task example NAME=logview
logview.yaml
# logview.yaml — a streaming-log style pane.
#
# Keys when focused:
#   /       open search (esc clears, enter blurs keeping query)
#   n / N   next / previous match
#   \       toggle filter mode — hides non-matching lines
#   g / G   top / bottom (G also re-engages auto-follow)
#   pgup/pgdn, arrows, mouse-wheel scroll
#
# Schema knobs:
#   lines:           initial buffer (more arrive via data sources later)
#   searchable:      enable / overlay
#   max_lines:       cap (0=default 10000, -1=unbounded)
#   filter_mode:     start in filter mode (only matching lines visible)
#   initial_query:   pre-populate the search query
app:
  title: Logview Demo
  theme: nord

tui:
  components:
    app_log:
      type: logview
      title: app.log
      searchable: true
      max_lines: 5000
      filter_placeholder: "search…"
      initial_query: ERROR
      filter_mode: false
      lines:
        - "2026-05-16T08:00:01Z INFO  app starting up (pid=4242)"
        - "2026-05-16T08:00:01Z INFO  loaded config from /etc/app/config.yaml"
        - "2026-05-16T08:00:02Z INFO  connected to postgres@db.local:5432"
        - "2026-05-16T08:00:03Z INFO  http server listening on :8080"
        - "2026-05-16T08:01:14Z DEBUG request id=abc-001 method=GET path=/api/users"
        - "2026-05-16T08:01:14Z DEBUG request id=abc-001 status=200 duration=12ms"
        - "2026-05-16T08:02:31Z WARN  request id=abc-002 slow query 312ms"
        - "2026-05-16T08:03:00Z ERROR request id=abc-003 panic: nil pointer dereference"
        - "2026-05-16T08:03:00Z ERROR request id=abc-003 stack trace truncated"
        - "2026-05-16T08:03:01Z INFO  request id=abc-004 method=POST path=/api/login"
        - "2026-05-16T08:03:01Z WARN  request id=abc-004 auth failed user=root"
        - "2026-05-16T08:03:02Z INFO  request id=abc-005 method=GET path=/api/users/42"
        - "2026-05-16T08:03:02Z DEBUG request id=abc-005 cache hit"
        - "2026-05-16T08:04:00Z ERROR background-worker connection refused to redis:6379"
        - "2026-05-16T08:04:00Z INFO  retrying redis in 5s"
        - "2026-05-16T08:04:05Z INFO  redis reconnected"
        - "2026-05-16T08:05:30Z DEBUG metrics flush 142 series"
        - "2026-05-16T08:06:12Z INFO  graceful shutdown signal received"
        - "2026-05-16T08:06:12Z INFO  draining 3 in-flight requests"
        - "2026-05-16T08:06:13Z INFO  shutdown complete"
  screen:
    title: Logs
    layout:
      component: app_log

textview

Static text with content:, plus a source-bound clock; / search and w wrap.

task example NAME=textview
textview.yaml
# textview.yaml — static-text viewer with /-search + `w` wrap toggle.
#
# `type: textview` (feature F from the tui-builder integration batch) is
# the "show me a document" primitive — kubectl describe output, help
# pages, markdown blobs, any text you want scrolled, searched, wrapped.
# Distinct from logview: logview streams, appends, and rings a tail;
# textview replaces the whole buffer on refresh via tuilib's SetContent.
#
# Modes:
#   - Static content: `content: |` inline in YAML (this example).
#   - Source-bound:   `source: <name>` — any `format: text` source
#                     works (exec / http / file). The response body
#                     becomes the buffer wholesale on each fetch.
#
# Keys when focused:
#   ↓/j ↑/k          scroll
#   g / G            top / bottom
#   ctrl+u / ctrl+d  half page
#   pgup / pgdown    page
#   /                search (esc clears)
#   n / N            next / previous match
#   w                toggle wrap
#
# The paired example below stacks a static help pane on top of a live
# `date`-emitting exec pane, so you can see both modes side by side.
app:
  title: Textview demo
  theme: nord

data:
  sources:
    now:
      type:    exec
      command: [sh, -c, "printf 'Current time (refreshed every 3s):\\n\\n  %s' \"$(date)\""]
      format:  text
      refresh: 3s

tui:
  components:
    help:
      type:       textview
      title:      Help (static)
      searchable: true
      wrap:       true
      content: |
        # Textview help

        This pane is populated via the config's `content:` field — no
        data source involved. Use it for onboarding notes, keybind
        references, or any pinned document.

        Try:
          /         search for a term
          n / N     next / previous match
          w         toggle word-wrap
          g / G     top / bottom
          j / k     scroll

        Source-bound textviews (see the right pane) work the same way
        but replace the buffer on every fetch. Cursor + query survive
        the swap.

    clock:
      type:       textview
      title:      Now (source-bound, refresh 3s)
      searchable: true
      source:     now

  screen:
    title: Textview
    layout:
      hstack:
        - {flex: 1, component: help}
        - {flex: 1, component: clock}

tree

A static hierarchy: expand, collapse, search, initial_depth.

task example NAME=tree
tree.yaml
# tree.yaml — hierarchical view with expand / collapse / search.
#
# Keys when focused:
#   ↓/j ↑/k          cursor
#   →/l              expand (or descend into already-expanded children)
#   ←/h              collapse (or jump to parent)
#   space            toggle
#   E / C            expand-all / collapse-all
#   g / G            top / bottom
#   /                search (esc clears)
#   n / N            next / previous match
#   \                filter mode — hides non-matching subtrees
#
# Schema:
#   root:             recursive {label, children} structure (required)
#   searchable:       enable /
#   initial_depth:    0=root only, 1=root expanded, 2=root + first level, …
#   initial_query:    pre-populate the search query
app:
  title: Tree Demo
  theme: nord

tui:
  components:
    project:
      type: tree
      title: Project
      searchable: true
      initial_depth: 2
      root:
        label: tui-builder
        children:
          - label: cmd
            children:
              - label: tui-builder
                children:
                  - {label: main.go}
          - label: internal
            children:
              - label: build
                children:
                  - {label: component.go}
                  - {label: layout.go}
              - label: config
                children:
                  - {label: config.go}
                  - {label: load.go}
              - label: screen
                children:
                  - {label: screen.go}
          - label: examples
            children:
              - {label: demo.yaml}
              - {label: list.yaml}
              - {label: table.yaml}
              - {label: table_columns.yaml}
              - {label: table_styled.yaml}
              - {label: logview.yaml}
              - {label: tree.yaml}
              - {label: layout.yaml}
              - {label: themes.yaml}
          - label: docs
            children:
              - {label: components.md}
          - {label: go.mod}
          - {label: go.sum}
  screen:
    title: Project tree
    layout:
      component: project

tree_source

A source-bound tree bucketed with group_by:, keeping its state across refresh:.

task example NAME=tree_source
tree_source.yaml
# tree_source.yaml — data-driven tree with live updates.
#
# Feature A from the tui-builder integration batch: `type: tree` now
# accepts a `source:` binding. The tree rebuilds in place on every
# fetch via tuilib.tree.SetRoot — expand state and cursor survive
# refreshes, so periodic polling doesn't clobber the view.
#
# Two shapes:
#
#   Flat (leaves under root):
#     tree:
#       source: <name>
#       label:  <dot-path>         # each leaf's display label
#
#   Grouped (parents per bucket, leaves under each parent — kubectl-shape):
#     tree:
#       source:   <name>
#       label:    <dot-path>       # leaf label
#       group_by: <dot-path>       # bucket by this value
#
# This example uses the same fixture as `file_fixture.yaml` (a flat list
# of people) and buckets by `team`, producing:
#
#   engineering
#   ├── Platform
#   │   ├── Ada Lovelace
#   │   ├── Alan Turing
#   │   └── Donald Knuth
#   ├── Engineering
#   │   └── Grace Hopper
#   ├── Reliability
#   │   └── Margaret Hamilton
#   └── ...
#
# Edit examples/fixtures/people.json in another window while this is
# running (`refresh: 5s`) — cursor + expand state survive each reload.
app:
  title: Team tree
  theme: nord

data:
  sources:
    people:
      type:    file
      path:    examples/fixtures/people.json
      root:    people      # extract the list from `{"people": [...]}`
      refresh: 5s

tui:
  components:
    org:
      type:          tree
      title:         Team tree
      searchable:    true
      initial_depth: 2
      source:        people
      root_label:    engineering
      group_by:      team    # bucket by this field
      label:         name    # leaves show this field
  screen:
    title: Org
    layout:
      component: org

inspector

A label/value record viewer with nested groups.

task example NAME=inspector
inspector.yaml
# inspector.yaml — two-column label/value record viewer with nested groups.
#
# Use for "show me the fields of this thing" — pod specs, REST responses,
# any structured record. Children render under their parent with indent +
# ▸/▾ expand glyphs; sibling labels auto-align so columns read clean.
#
# Keys when focused:
#   ↓/j ↑/k          row cursor
#   g / G            top / bottom
#   ctrl+u / ctrl+d  half page
#   space            toggle the cursor row's expand state
#   E / C            expand-all / collapse-all
#   /                search (esc clears)
#   n / N            next / previous match
#   \                filter mode — hides non-matching subtrees, keeps ancestors
#   ← / →            horizontal scroll long values
app:
  title: Inspector Demo
  theme: nord

tui:
  components:
    pod:
      type: inspector
      title: pod/nginx-7c64f5d
      filterable: true
      initial_depth: 2
      fields:
        - {label: name,       value: nginx-7c64f5d-abc12}
        - {label: namespace,  value: production}
        - {label: status,     value: Running}
        - {label: node,       value: gke-prod-pool-3-9f2c}
        - label: metadata
          children:
            - {label: uid,             value: 7f2a3c1d-9b4e-4f0a-b9b6-2e7c5a3f4b1c}
            - {label: creationTime,    value: "2026-05-30T08:14:22Z"}
            - label: labels
              children:
                - {label: app,         value: nginx}
                - {label: tier,        value: frontend}
                - {label: environment, value: production}
            - label: annotations
              children:
                - {label: deploy.commit, value: 4f8c2a1}
                - {label: deploy.by,     value: ci-bot@example.com}
        - label: spec
          children:
            - {label: restartPolicy, value: Always}
            - {label: serviceAccount, value: default}
            - label: containers
              children:
                - label: nginx
                  children:
                    - {label: image, value: "nginx:1.25-alpine"}
                    - {label: imagePullPolicy, value: IfNotPresent}
                    - label: resources
                      children:
                        - label: limits
                          children:
                            - {label: cpu,    value: "500m"}
                            - {label: memory, value: "256Mi"}
                        - label: requests
                          children:
                            - {label: cpu,    value: "100m"}
                            - {label: memory, value: "128Mi"}
                    - label: ports
                      children:
                        - {label: "[0].containerPort", value: "80"}
                        - {label: "[0].protocol",      value: TCP}
                - label: log-sidecar
                  children:
                    - {label: image, value: "fluentbit:2.2"}
                    - {label: imagePullPolicy, value: IfNotPresent}
        - label: status.conditions
          children:
            - label: "[0]"
              children:
                - {label: type,   value: Ready}
                - {label: status, value: "True"}
                - {label: lastTransitionTime, value: "2026-05-30T08:14:29Z"}
            - label: "[1]"
              children:
                - {label: type,   value: Initialized}
                - {label: status, value: "True"}
  screen:
    title: Inspector
    layout:
      component: pod

inspector_auto

auto: true: an inspector that derives its fields from any JSON response.

task example NAME=inspector_auto
inspector_auto.yaml
# inspector_auto.yaml — auto-derive fields from any JSON response.
#
# Instead of declaring every `{label:, path:}` up front, set `auto: true`
# and the inspector walks the fetched value directly:
#
#   - map[string]any → one field per key (keys sorted alphabetically for
#     determinism), with nested maps rendered as expandable Children
#     rather than stringified.
#   - []any → numbered `[0]`, `[1]`, … children.
#   - scalars → their natural rendering (numbers without trailing `.0`,
#     booleans as `true`/`false`, `nil` as `null`).
#
# This is the "show me whatever this endpoint returns" mode — handy for
# REST responses you haven't mapped yet, k8s manifests, or any object
# whose shape varies too much to declare statically.
#
# Trade-offs:
#   - No column formatting / no ColorRules (they're per-declared-field).
#   - Labels are the raw JSON keys — `metadata`, not `Metadata`. If you
#     want relabeling or coloring, switch to the declared `fields:` mode.
#
# The example fetches a single GitHub repo record — a good stand-in
# because the response has strings, numbers, booleans, nested objects
# (`owner`, `license`, `permissions`), and arrays (`topics`).
app:
  title: Inspector auto-derive
  theme: nord

data:
  sources:
    repo:
      type: http
      url:     https://api.github.com/repos/charmbracelet/bubbletea
      refresh: 10m
      timeout: 15s
      headers:
        Accept:               application/vnd.github+json
        X-GitHub-Api-Version: "2022-11-28"

tui:
  components:
    repo_inspector:
      type: inspector
      title: charmbracelet/bubbletea
      filterable: true
      initial_depth: 1     # show top-level keys, hide their subtrees until expanded
      source: repo
      auto: true           # walk the response; no `fields:` needed

  screen:
    title: Repo (auto)
    layout:
      component: repo_inspector

marking

Multi-select with markable: and mark_key:; marks stay on rows that reorder every poll.

task example NAME=marking
marking.yaml
# marking.yaml — multi-select via `markable:`.
#
# A markable component grows a mark gutter and binds:
#
#   x    toggle the mark under the cursor
#   X    extend from the last mark to the cursor (range)
#   A    mark all
#   D    mark none
#
# Marks are held by KEY, never by index. That is the whole design: a
# source that repolls can return the same rows in a different order, and
# an index-held mark would slide onto whichever row now occupies that
# slot. Try it — mark a pod below, then wait for the 3s refresh. The
# fixture reverses its order every read, and your mark stays put.
#
# Where the key comes from depends on the kind:
#
#   list    the item's display string (duplicates collapse onto one
#           mark, which is the right answer for a list of names)
#   tree    the node's path — the same identity tuilib already uses for
#           expansion state, so marking there needs no configuration
#   table   `mark_key:`, read from the ORIGINAL source item rather than
#           the rendered cells. So the identity can be a field the table
#           never displays, which is usually the right one.
#
# `mark_key:` is REQUIRED on a source-bound markable table, and is
# deliberately not defaulted to the first column. Both obvious defaults
# fail silently and data-dependently:
#
#   non-unique  a Name column that repeats across namespaces collapses
#               two rows onto one key, so marking one marks both
#   volatile    an AGE / STATUS / RESTARTS column changes on the next
#               poll, so the marks evaporate or land elsewhere
#
# One load error the author fixes once beats a selection that goes wrong
# at 2am. Note the table below keys on `uid`, a column it never shows.
#
# A surprise worth knowing: marks survive filtering. A key does not care
# whether its row is on screen, so you can mark a row, filter it away,
# and still be holding it.
# Marks are what `multi: true` acts on. Press `a` with rows marked and
# the menu titles itself "3 items"; a verb without `multi: true` shows
# up disabled with a reason rather than silently picking one of them.
# A multi verb fans out into one run per row — separately tracked,
# separately cancellable, each logged under its own name in `o`.
app:
  title: Marking
  theme: nord

actions:
  touch:
    description: Echo one pod's name — fans out over marked rows
    multi: true
    inputs:
      name: {type: string, required: true}
    run: [echo, "touched ${inputs.name}"]

  count:
    description: Not multi — disabled while more than one row is marked
    inputs:
      name: {type: string, required: true}
    run: [echo, "only ${inputs.name}"]

data:
  sources:
    pods:
      type: exec
      # Emits the same three pods every time, but reverses their order
      # on each call — the reordering-poll case, made deterministic.
      command:
        - sh
        - -c
        - |
          if [ -f /tmp/tui-builder-mark-flip ]; then
            rm -f /tmp/tui-builder-mark-flip
            printf '%s' '[{"uid":"u3","name":"db","status":"Running"},{"uid":"u2","name":"api","status":"CrashLoopBackOff"},{"uid":"u1","name":"web","status":"Running"}]'
          else
            touch /tmp/tui-builder-mark-flip
            printf '%s' '[{"uid":"u1","name":"web","status":"Running"},{"uid":"u2","name":"api","status":"CrashLoopBackOff"},{"uid":"u3","name":"db","status":"Running"}]'
          fi
      refresh: 3s

tui:
  components:
    pods:
      type: table
      title: Pods — mark with x, then watch the 3s refresh reorder them
      filterable: true
      markable: true
      source: pods
      # The identity. Never rendered — there is no uid column below.
      mark_key: uid
      columns:
        - {title: Name,   width: 20, sortable: true, value: name}
        - {title: Status, width: 24, sortable: true, value: status}

    fruits:
      type: list
      title: List — keys on the item string
      filterable: true
      markable: true
      items: [Apple, Apricot, Banana, Blueberry, Cherry, Date, Fig, Grape]

    project:
      type: tree
      title: Tree — keys on the node path
      searchable: true
      markable: true
      initial_depth: 2
      root:
        label: tui-builder
        children:
          - label: internal
            children:
              - {label: build}
              - {label: config}
          - label: examples
            children:
              - {label: marking.yaml}
              - {label: chrome.yaml}

  screen:
    actions:
      - action: touch
        label:  touch
        from:   pods
        # One confirm gates the whole fan-out, not one per row. The
        # count is appended automatically — the author's template
        # resolved against a single row, so alone it would name one pod
        # while touching three.
        confirm: "Touch ${selection.Name}?"
        bind:   {name: "${selection.Name}"}
      - action: count
        label:  count
        from:   pods
        bind:   {name: "${selection.Name}"}
    title: Multi-select
    layout:
      hstack:
        - flex: 2
          component: pods
        - flex: 1
          vstack:
            - {flex: 1, component: fruits}
            - {flex: 1, component: project}

inputs

An input and a toggle driving a table's source through on_input: and ${input.NAME}.

task example NAME=inputs
inputs.yaml
# inputs.yaml — an input and a toggle that drive a source.
#
# `type: input` is a one-line text field and `type: toggle` a yes/no
# switch. Neither displays a source: other components read their
# committed values as ${input.NAME} and refetch when they change.
#
#   tab      move onto the Search field and start typing
#   enter    commit — the table refetches with the new query
#   esc      put the committed text back and leave the field
#   ← → / ␣  flip the toggle; every flip commits
#
# The table's source is an exec command with two parameters; `on_input`
# binds them. It runs once at start with the `initial:` values, then on
# every commit. The same token works in actions: `enter` on a row echoes
# the current search alongside the pod's name.
app:
  title: Inputs
  theme: nord

actions:
  note:
    description: Echo the focused pod and the search that found it
    inputs:
      name: {required: true}
    run: [echo, "${inputs.name} (search: ${input.search})"]

data:
  sources:
    pods:
      type: exec
      parameters:
        q: {type: string, default: ""}
        deleted: {type: bool, default: "false"}
      # A fixture with a deleted pod; grep -i filters by the query, and
      # deleted pods are dropped unless the toggle is on.
      command:
        - sh
        - -c
        - |
          rows='web Running
          api CrashLoopBackOff
          db Running
          old-batch Deleted'
          printf '%s\n' "$rows" | sed 's/^ *//' \
            | { if [ "${params.deleted}" = true ]; then cat; else grep -v Deleted; fi; } \
            | grep -i -- "${params.q}" \
            | awk 'BEGIN{printf "["} {printf "%s{\"name\":\"%s\",\"status\":\"%s\"}", (NR>1?",":""), $1, $2} END{print "]"}'

tui:
  components:
    search:
      type: input
      title: Search
      placeholder: name…
    show_deleted:
      type: toggle
      title: Deleted pods
      yes_label: shown
      no_label: hidden
    pods_table:
      type: table
      title: Pods
      source: pods
      on_input:
        bind: {q: "${input.search}", deleted: "${input.show_deleted}"}
      columns:
        - {title: Name, value: name, flex: 1}
        - title: Status
          value: status
          color_rules:
            - {when: Running, color: green}
            - {when: "~Crash", color: red}
            - {when: Deleted, color: gray}

  screen:
    title: Inputs
    layout:
      vstack:
        - hstack:
            - {flex: 2, component: search}
            - {flex: 1, component: show_deleted}
          fixed: 3
        - {component: pods_table}
    actions:
      - {key: enter, action: note, from: pods_table, bind: {name: "${selection}"}}

metrics

Inline badges, ratios, progress bars and sparklines with metric: on table columns and inspector fields.

task example NAME=metrics
metrics.yaml
# metrics.yaml — inline badges, ratios, bars and sparklines.
#
# `metric:` on a table column (or an inspector field) draws a small
# summary instead of text, through tuilib's pkg/metrics:
#
#   badge  [ok, warn, down]  status counts       12✓ 3⚠ 1✗
#   ratio  [done, total]     colored by progress 3/5
#   bar    value + max       progress bar        ▰▰▰▱▱
#   spark  a list of numbers sparkline           ▁▃▅▇
#
# Inputs are dot-paths into each row, in that order. Anything computed
# belongs in a `derive` operator; a metric only reads fields.
#
# A metric column's selection is its first input, not its glyphs. Focus
# a row and press `a` → "scale": the confirm shows the ready count read
# from the Ready column, ${selection.Ready}.
app:
  title: Metrics
  theme: nord

actions:
  scale:
    description: Echo the focused deployment's ready count
    inputs:
      name: {required: true}
      ready: {required: true}
    run: [echo, "${inputs.name} has ${inputs.ready} ready"]

data:
  sources:
    deployments:
      type: static
      data:
        - {name: web,   ready: 3, desired: 3, ok: 12, warn: 0, down: 0, cpu: 42, latency: [12, 14, 13, 18, 22, 19, 15, 14]}
        - {name: api,   ready: 1, desired: 3, ok: 7,  warn: 3, down: 1, cpu: 87, latency: [30, 34, 41, 55, 72, 90, 85, 88]}
        - {name: queue, ready: 0, desired: 2, ok: 0,  warn: 0, down: 4, cpu: 3,  latency: [5, 5, 6, 5, 4, 5, 5, 6]}
    cluster:
      type: static
      data: {nodes_ready: 4, nodes: 5, ok: 19, warn: 3, down: 5, memory: 61, load: [0.4, 0.6, 0.9, 1.4, 1.1, 0.8]}

tui:
  components:
    deployments:
      type: table
      title: Deployments
      source: deployments
      columns:
        - {title: Name, value: name, flex: 1}
        - {title: Ready, width: 6, metric: {ratio: [ready, desired]}}
        - {title: Pods, width: 12, metric: {badge: [ok, warn, down]}}
        - {title: CPU, width: 12, metric: {bar: cpu, max: 100}}
        - {title: Latency, width: 10, metric: {spark: latency, color: cyan}}
    cluster:
      type: inspector
      title: Cluster
      source: cluster
      fields:
        - {label: Nodes, metric: {ratio: [nodes_ready, nodes]}}
        - {label: Pods, metric: {badge: [ok, warn, down]}}
        - {label: Memory, metric: {bar: memory, max: 100, width: 20}}
        - {label: Load, metric: {spark: load, width: 20}}

  screen:
    title: Metrics
    layout:
      hstack:
        - {flex: 2, component: deployments}
        - {flex: 1, component: cluster}
    actions:
      - action: scale
        label: scale
        from: deployments
        bind: {name: "${selection.Name}", ready: "${selection.Ready}"}
        confirm: "Scale ${selection.Name}? It has ${selection.Ready} ready."

Layout, theme and chrome

layout

Nested vstack / hstack with mixed flex weights.

task example NAME=layout
layout.yaml
# layout.yaml — nested layout primitives.
#
# Nodes: vstack | hstack | zstack | component (ref by name).
# Items inside a stack carry sizing:
#   flex: K    proportional share of leftover space (default if both unset)
#   fixed: N   exact number of rows (vstack) or cols (hstack)
#
# Tab cycles focus between components in walk order.
app:
  title: Layout Showcase
  theme: dracula

tui:
  components:
    left_top:
      type: list
      title: Top-Left
      filterable: true
      items: [alpha, beta, gamma, delta, epsilon, zeta, eta, theta]

    left_bottom:
      type: list
      title: Bottom-Left
      items: [North, South, East, West]

    right:
      type: table
      title: Right
      filterable: true
      columns:
        - {title: Key,   width: 12, sortable: true}
        - {title: Value, width: 18}
        - {title: Rank,  width: 6, sortable: true, sort: number, align: right}
      rows:
        - [alpha,   primary,   1]
        - [beta,    secondary, 2]
        - [gamma,   tertiary,  3]
        - [delta,   quaternary,4]
  screen:
    title: Layout
    layout:
      hstack:
        - flex: 1
          vstack:
            - {flex: 2, component: left_top}
            - {flex: 1, component: left_bottom}
        - flex: 2
          component: right

tabs

A tabbed screen: tabs: in place of layout:, each tab with its own layout and actions; hidden tabs keep polling.

task example NAME=tabs
tabs.yaml
# tabs.yaml — a tabbed screen.
#
# `tabs:` replaces `layout:`. Each tab is a small screen of its own —
# a label, a layout, actions — shown one at a time under a strip:
#
#   shift+left / shift+right   previous / next tab
#   1–9                        jump straight to a tab
#   click                      a tab in the strip
#
# Keys go to the active tab: tab / shift+tab move focus inside it, and
# its own bindings fire. A hidden tab first fetches when you open it,
# then keeps polling while hidden — the Events tab's clock keeps ticking
# while you're on Pods. The breadcrumb stays "Cluster": switching tabs
# isn't moving between screens.
app:
  title: Tabs
  theme: nord

actions:
  describe:
    description: Echo the focused pod
    inputs:
      name: {required: true}
    run: [echo, "pod ${inputs.name}"]

data:
  sources:
    pods:
      type: static
      data:
        - {name: web-7f9c, status: Running, node: node-a}
        - {name: api-5d2b, status: CrashLoopBackOff, node: node-b}
        - {name: db-0, status: Running, node: node-c}
    services:
      type: static
      data:
        - {name: web, type: LoadBalancer, port: 443}
        - {name: api, type: ClusterIP, port: 8080}
    events:
      type: exec
      refresh: 2s
      format: text
      command: [sh, -c, "date '+%H:%M:%S  heartbeat from the Events tab'"]

tui:
  components:
    pods_table:
      type: table
      title: Pods
      source: pods
      columns:
        - {title: Name, value: name, flex: 1}
        - title: Status
          value: status
          color_rules:
            - {when: Running, color: green}
            - {when: "~Crash", color: red}
        - {title: Node, value: node}
    pod_detail:
      type: inspector
      title: First pod
      source: pods
      auto: true
    services_table:
      type: table
      title: Services
      source: services
      columns:
        - {title: Name, value: name, flex: 1}
        - {title: Type, value: type}
        - {title: Port, value: port, align: right}
    events_log:
      type: logview
      title: Events
      source: events

  screen:
    title: Cluster
    initial: Pods
    tabs:
      - label: Pods
        layout:
          hstack:
            - {flex: 2, component: pods_table}
            - {flex: 1, component: pod_detail}
        actions:
          - {key: enter, action: describe, from: pods_table, bind: {name: "${selection}"}}
      - label: Services
        layout: {component: services_table}
      - label: Events
        layout: {component: events_log}

themes

Every built-in theme.

task example NAME=themes
themes.yaml
# themes.yaml — pick a built-in theme via app.theme.
#
# Recognized names (from tuilib's theme.All()):
#   dark, accent, light, solarized, nord, dracula, gruvbox,
#   catppuccin-mocha, catppuccin-latte, tokyonight,
#   rose-pine, rose-pine-dawn, onedark, monokai, everforest-dark,
#   base16-ocean, base16-eighties, base16-railscasts, base16-tomorrow-night
#
# Press `t` to cycle through every loaded theme live.
# Unknown names fall back silently to the first theme in the list.
app:
  title: Theme Picker
  theme: gruvbox        # change me and re-run; or press `t` at runtime

tui:
  components:
    preview:
      type: table
      title: Palette preview
      filterable: true
      columns:
        - {title: Token,        width: 22}
        - {title: Role,         width: 32}
        - {title: Use,          width: 32}
      rows:
        - ["BarBG / BarFG",     "Breadcrumb + statusbar surface",   "Header + footer band"]
        - ["Current",           "Active crumb / highlight",         "Selection emphasis"]
        - ["Muted",             "Past crumbs / secondary text",     "De-emphasized text"]
        - ["Subtle",            "Separators / faint chrome",        "Selected-row bg"]
        - ["KeyFG",             "Help-hint key labels",             "Bold key chips"]
        - ["BorderActive",      "Focused pane border",              "Where-am-I cue"]
        - ["BorderInactive",    "Unfocused pane border",            "Quiet borders"]
        - ["Accent",            "Body highlight",                   "Sort marker / spinner"]
        - ["InfoBG / InfoFG",   "Statusbar info state",             "Success messages"]
        - ["ErrorBG / ErrorFG", "Statusbar error state",            "Failure messages"]
  screen:
    title: Theme preview
    layout:
      component: preview

chrome

app.glyphs and app.borders: an ASCII-safe glyph set, rounded panes and slot brackets.

task example NAME=chrome
chrome.yaml
# chrome.yaml — the glyph vocabulary and border shapes, via app.glyphs
# and app.borders.
#
# A theme picks colors. These two blocks pick the *marks* and the
# *lines* — the characters components draw for cursors, arrows,
# scrollbars and column rules, and the border style around each pane.
# They are deliberately separate from `theme:` and apply to every
# palette: press `t` to cycle themes and watch the colors change while
# the chrome below stays put.
#
# This example uses a pure-ASCII glyph set, which is the case that
# actually comes up: a terminal or font without box-drawing coverage
# renders tuilib's defaults (▸ ✓ █ │ ▲) as tofu boxes. Swapping the
# vocabulary fixes the whole app at once instead of per component.
#
# Every field in both blocks is optional. An unset one keeps tuilib's
# default, so overriding one arrow does not blank the other twelve —
# `sort_asc`/`sort_desc` below are left alone on purpose, and still
# render as ▲ / ▼.
#
# What to look at:
#   - the `>` cursor in every pane (default is ▸)
#   - `|` between table columns, `-` under an open filter (press /)
#   - `+` / `-` disclosure arrows in the tree (default ▾ / ▸)
#   - `#` scrollbar thumbs — narrow a pane or press G to see one
#   - rounded pane corners, and titles bracketed as `( title )`
#   - press `?` for the key overlay: a modal, so it takes `overlay`
#     (double lines here) rather than the rounded pane shape — and the
#     glyphs follow it in, cursor and filter rule included
#   - press `o` for the output console — a pushed screen, not a modal, so
#     it takes the pane shape (`active`). The distinction is the whole
#     reason `overlay` is its own knob.
app:
  title: Chrome
  theme: nord

  glyphs:
    cursor:         ">"     # focused row  (default ▸)
    mark:           "*"     # marked row   (default ✓)
    expand_open:    "-"     # tree / inspector disclosure (default ▾)
    expand_closed:  "+"     #                             (default ▸)
    rule:           "-"     # line under an inline filter (default ─)
    scroll_thumb:   "#"     # vertical scrollbar          (default █)
    scroll_track:   "."     #                             (default ░)
    h_scroll_thumb: "="     # horizontal scrollbar        (default ━)
    h_scroll_track: "-"     #                             (default ─)
    column_sep:     "|"     # between table columns       (default │)
    placeholder:    "~"     # unfetched row in a windowed table (default ·)
    # sort_asc / sort_desc left unset — they stay ▲ / ▼, which is the
    # point: a partial block is a partial override, not a reset.

  borders:
    active:   rounded       # normal | rounded | thick | double | hidden | block | ascii
    inactive: rounded       # same shape as active on purpose: focus is
                            # signalled by border *color*, and a pane that
                            # changed weight on focus would move your eye
                            # for a reason you didn't ask about
    overlay:  double        # the key overlay (`?`), confirm + alert dialogs,
                            # and the action menu — what is drawn *over* a
                            # screen rather than inside it. A heavier line
                            # is what separates an overlay from the pane it
                            # covers. The output console is a pushed
                            # screen, not a modal, so it takes `active`.
    slot_brackets: corners  # how a title meets the border line:
                            #   none     -- title --   (default)
                            #   corners  ) title (     a labelled tab
                            #   tees    -| title |-

tui:
  components:
    fruits:
      type: list
      title: List — cursor
      filterable: true
      items:
        - Apple
        - Apricot
        - Banana
        - Blueberry
        - Cherry
        - Date
        - Elderberry
        - Fig
        - Grape
        - Kiwi
        - Lemon
        - Mango
        - Nectarine
        - Orange
        - Papaya
        - Quince
        - Raspberry
        - Strawberry

    cities:
      type: table
      title: Table — separators, sort arrows
      filterable: true
      initial_sort: {column: Pop, desc: true}
      columns:
        - {title: City,   width: 16, sortable: true}
        - {title: Region, width: 12, sortable: true}
        - {title: Pop,    width: 10, sortable: true, sort: number, align: right}
      rows:
        - [London,    Europe,   "9000000"]
        - [Tokyo,     Asia,     "37000000"]
        - [Lima,      Americas, "10000000"]
        - [Reykjavík, Europe,   "130000"]
        - [Nairobi,   Africa,   "4400000"]
        - [Sydney,    Oceania,  "5300000"]

    project:
      type: tree
      title: Tree — disclosure arrows
      searchable: true
      initial_depth: 2
      root:
        label: tui-builder
        children:
          - label: internal
            children:
              - label: build
                children:
                  - {label: chrome.go}
                  - {label: component.go}
              - label: config
                children:
                  - {label: config.go}
                  - {label: load.go}
          - label: examples
            children:
              - {label: chrome.yaml}
              - {label: themes.yaml}
          - {label: go.mod}

  screen:
    title: Glyphs and borders
    layout:
      hstack:
        - flex: 1
          component: fruits
        - flex: 2
          vstack:
            - {flex: 1, component: cities}
            - {flex: 1, component: project}

colors

Per-component colors: overrides.

task example NAME=colors
colors.yaml
# colors.yaml — per-component color overrides.
#
# Every component accepts an optional `colors:` block whose fields map
# directly to tuilib's underlying Options. Unset fields fall through to
# the active theme — so you can tweak one or two tokens per component
# without redefining the whole palette.
#
# Value formats:
#   named         red, green, yellow, blue, magenta, cyan, white,
#                 gray (= bright_black), bright_* variants
#   palette       any 0-255 index as a string: "208", "33"
#   hex           "#ff8800"
#
# Fields by component:
#
#   list           border_active, border_inactive, selected, spinner
#   table          border_active, border_inactive,
#                  header, selected_fg, selected_bg, cell,
#                  column_separator, header_rule, spinner
#   inspector      border_active, border_inactive,
#                  label, value, match, current_line_bg, spinner
#   logview        border_active, border_inactive,
#                  match, current_line_bg, spinner
#   tree           border_active, border_inactive,
#                  match, current_line_bg, spinner
#
# Tab between panes — every component has a different override scheme to
# show what each token affects.
app:
  title: Color overrides
  theme: nord

tui:
  components:
    fruits:
      type: list
      title: List (selected = orange, active border = magenta)
      filterable: true
      items: [Apple, Apricot, Banana, Blueberry, Cherry, Date, Fig, Grape, Kiwi, Lemon, Mango]
      colors:
        border_active: magenta
        selected: "208"           # bright orange (256-color)

    cities:
      type: table
      title: Table (red header, white-on-blue selection, cyan separators)
      filterable: true
      columns:
        - {title: City,    width: 18}
        - {title: Region,  width: 14}
        - {title: Pop,     width: 10, align: right}
      rows:
        - [London,       Europe,   "9M"]
        - [Tokyo,        Asia,     "37M"]
        - [Lima,         Americas, "10M"]
        - [Reykjavík,    Europe,   "130K"]
      colors:
        border_active: "#ff8800"
        header: red
        selected_fg: bright_white
        selected_bg: blue
        column_separator: cyan
        header_rule: cyan

    details:
      type: inspector
      title: Inspector (gray labels, yellow values, magenta cursor row)
      initial_depth: 2
      fields:
        - {label: name,     value: nginx-7c64f5d}
        - {label: status,   value: Running}
        - label: spec
          children:
            - {label: image,    value: "nginx:1.25"}
            - {label: replicas, value: "3"}
      colors:
        border_active: green
        label: gray
        value: bright_yellow
        current_line_bg: magenta
  screen:
    title: Colors
    layout:
      hstack:
        - flex: 1
          component: fruits
        - flex: 2
          vstack:
            - {flex: 2, component: cities}
            - {flex: 1, component: details}

demo

A list and a table side by side.

task example NAME=demo
demo.yaml
app:
  title: TUIQuery Demo
  version: v0.1.0
  theme: nord

tui:
  components:
    cities:
      type: list
      title: Cities
      filterable: true        # '/' to search, esc to clear
      items:
        - London
        - Tokyo
        - Madrid
        - Lima
        - Reykjavík
        - Cairo
        - Buenos Aires

    population:
      type: table
      title: Population
      filterable: true        # '/' to search; supports key:val and ~regex
      columns:
        - {title: City,   width: 16, sortable: true}
        - {title: Region, width: 14, sortable: true}
        - {title: Pop,    width: 8,  sortable: true, sort: si, align: right}
      rows:
        - [London,       Europe,   9M]
        - [Tokyo,        Asia,     13M]
        - [Madrid,       Europe,   3M]
        - [Lima,         Americas, 10M]
        - [Reykjavík,    Europe,   130K]
        - [Cairo,        Africa,   10M]
        - [Buenos Aires, Americas, 15M]
  screen:
    title: Cities
    layout:
      hstack:
        - {flex: 1, component: cities}
        - {flex: 2, component: population}

Data sources

http_countries

A table over public JSON (the mledoze/countries dataset), polled with refresh: 5m, with per-column dot-paths.

task example NAME=http_countries
http_countries.yaml
# http_countries.yaml — table driven by public JSON over HTTP.
#
# The data is the mledoze/countries dataset (the one restcountries.com was
# built from), fetched from GitHub. It needs no account or API key, which
# restcountries.com itself now does.
#
# Lifecycle:
#   - On screen activate, every bound source fetches once.
#   - While in flight, the pane shows a centered spinner.
#   - Errors surface in the statusbar (red); successful payload SetRows's
#     the table in place.
#   - `r` re-fetches every source on this screen (suppressed while a
#     filter is engaged so 'r' typed into search isn't hijacked).
#   - `refresh:` on a source schedules polling — this example polls every
#     5 minutes (GitHub caches the file for 5 minutes anyway). The dataset
#     rarely changes; the point is that cursor, filter and sort survive
#     each refresh.
#
# Mapping syntax:
#   data_sources.countries.url           where to fetch
#   data_sources.countries.refresh       polling cadence (omit for fetch-once)
#   data_sources.countries.root          dot-path into the response that
#                                        identifies the iterable root (empty
#                                        when the response itself is an array)
#   components.countries.source          name of the data source
#   components.countries.columns[].value dot-path per cell, evaluated per
#                                        element of the iterable root
app:
  title: Countries
  theme: nord
data:
  sources:
    countries:
      type: http
      url: https://raw.githubusercontent.com/mledoze/countries/master/countries.json
      refresh: 5m
      timeout: 15s
    all_countries:
      type: passthrough
      from: countries
tui:
  components:
    countries:
      type: table
      title: Countries
      filterable: true
      columns:
        - {title: Code, width: 6, sortable: true, value: cca2}
        - {title: Name, width: 26, sortable: true, value: name.common}
        - {title: Region, width: 16, sortable: true, value: region}
        - {title: Capital, width: 22, sortable: true, value: capital.0}
        - {title: Area km², width: 14, sortable: true, sort: number, align: right, value: area}
      source: all_countries
  screen:
    title: Countries
    layout:
      component: countries

http_refresh

Prices polled every 10s; filter, sort and cursor survive each refresh.

task example NAME=http_refresh
http_refresh.yaml
# http_refresh.yaml — visible HTTP polling demo.
#
# Watch the table for ~30 seconds: the "Updated" column flips every cycle
# and the Price / 24h % cells drift as CoinGecko's quote stream changes.
# Press `r` at any time to force an immediate refetch (suppressed while
# the filter is engaged). Tab away from the table (then back) doesn't
# refetch — only the polling tick + the manual `r` do.
#
# Notes:
#   - refresh: 10s         polls every 10s after each successful fetch
#                          (so a slow request doesn't pile up overlapping
#                          fetches — the tick re-arms only after the
#                          previous response settles)
#   - timeout: 15s         caps each request — exceeding it surfaces an
#                          error in the statusbar but doesn't break the
#                          polling loop
#   - filter / sort / cursor all survive across refreshes (tuilib's
#     SetRows update is in-place)
app:
  title: Crypto prices (live)
  theme: nord

data:
  sources:
    prices:
      type: http
      url: https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=25&page=1&price_change_percentage=1h
      refresh: 10s
      timeout: 15s
      headers:
        Accept: application/json

tui:
  components:
    prices:
      type: table
      title: Top 25 by market cap (refreshes every 10s — watch the Updated column)
      source: prices
      filterable: true
      initial_sort: {column: Rank, desc: false}
      # Bind chrome tokens to the theme so cycling themes (`t`) re-skins
      # the table consistently with the rest of the app.
      colors:
        border_active:    theme:accent
        header:           theme:accent
        selected_fg:      theme:bar-fg
        selected_bg:      theme:current
        column_separator: theme:subtle
        header_rule:      theme:accent
      columns:
        - {title: Rank,    width: 6,  sortable: true, sort: number, align: right, value: market_cap_rank}
        - {title: Symbol,  width: 8,  sortable: true,                              value: symbol}
        - {title: Name,    width: 22, sortable: true,                              value: name}
        - {title: Price,   width: 14, sortable: true, sort: number, align: right, value: current_price}
        - {title: 1h %,    width: 9,  sortable: true, sort: number, align: right, value: price_change_percentage_1h_in_currency}
        - {title: 24h %,   width: 9,  sortable: true, sort: number, align: right, value: price_change_percentage_24h}
        - {title: Mkt Cap, width: 16, sortable: true, sort: number, align: right, value: market_cap}
        - {title: Volume,  width: 16, sortable: true, sort: number, align: right, value: total_volume}
        - {title: Updated, width: 24,                                              value: last_updated}
  screen:
    title: Prices
    layout:
      component: prices

http_github

A GitHub drill-down: users → repos → repo detail, templated from the selection.

task example NAME=http_github
http_github.yaml
# http_github.yaml — multi-screen drilldown over GitHub's public REST API.
#
# Flow:
#   users (static list)  ──enter──▶  repos table (HTTP)  ──enter──▶  repo detail (HTTP)
#
# Each push captures the parent selection and substitutes it into the
# child's data source URL — the level-2 source fetches
# /users/${selection}/repos, the level-3 source fetches
# /repos/${selection.Repo}.
#
# Polling is set to 5m so re-pushing the same screen reuses cached data
# initially (each new push is a fresh fetch anyway). Press `r` to refresh
# the current screen on demand.
app:
  title: GitHub Explorer
  theme: nord

data:
  sources:
    repos:
      type: http
      url: https://api.github.com/users/${selection}/repos?sort=updated&per_page=50
      refresh: 5m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json
    repo_detail:
      type: http
      url: https://api.github.com/repos/${selection.Repo}
      refresh: 5m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json

tui:
  components:
    users:
      type: list
      title: GitHub users
      filterable: true
      items:
        - torvalds
        - golang
        - charmbracelet
        - jsdrews
        - kubernetes
        - hashicorp

    repos_table:
      type: table
      title: ${selection}'s repos
      filterable: true
      source: repos
      initial_sort: {column: Stars, desc: true}
      columns:
        - {title: Repo,        width: 32, sortable: true, value: full_name}
        - {title: Language,    width: 14, sortable: true, value: language}
        - {title: Stars,       width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}
        - {title: Forks,       width: 8,  sortable: true, sort: number, align: right, value: forks_count}
        - {title: Updated,     width: 22, sortable: true, value: updated_at}
        - {title: Description, width: 40, value: description}

    repo_inspector:
      type: inspector
      title: ${selection.Repo}
      filterable: true
      initial_depth: 2
      source: repo_detail
      fields:
        - {label: Full name,  path: full_name}
        - {label: Description, path: description}
        - {label: Homepage,   path: homepage}
        - {label: Language,   path: language}
        - {label: License,    path: "license.spdx_id"}
        - {label: Default branch, path: default_branch}
        - label: Stats
          children:
            - {label: Stars,       path: stargazers_count}
            - {label: Watchers,    path: subscribers_count}
            - {label: Forks,       path: forks_count}
            - {label: Open issues, path: open_issues_count}
            - {label: Size (KB),   path: size}
        - label: Timestamps
          children:
            - {label: Created,  path: created_at}
            - {label: Updated,  path: updated_at}
            - {label: Pushed,   path: pushed_at}
        - label: Owner
          children:
            - {label: Login,    path: "owner.login"}
            - {label: Type,     path: "owner.type"}
            - {label: HTML URL, path: "owner.html_url"}
  screens:
    users:
      title: Users
      layout: {component: users}
      actions:
        - {key: enter, name: open_repos, push: repos, from: users}

    repos:
      title: ${selection}
      layout: {component: repos_table}
      actions:
        - {key: enter, name: open_detail, push: detail, from: repos_table}

    detail:
      title: ${selection.Repo}
      layout: {component: repo_inspector}
  initial: users

http_github_auth

Authenticated GitHub with ${env.GITHUB_TOKEN} in a header. Needs GITHUB_TOKEN.

task example NAME=http_github_auth
http_github_auth.yaml
# http_github_auth.yaml — authenticated GitHub demo.
#
# Drilldown:    /user/starred (your starred repos)  →  /repos/{full_name} (repo detail)
#
# Requires:     export GITHUB_TOKEN=ghp_...    (any classic PAT or fine-grained
#                                               token with `repo:read` is fine)
#
# Auth pattern: ${env.GITHUB_TOKEN} in the Authorization header — resolved at
# fetch time via os.Getenv. Tokens never live in YAML. If GITHUB_TOKEN is
# unset, the header reads "Bearer " and GitHub returns 401 — surfaced in the
# statusbar.
#
# Why authenticated:
#   - Rate limit jumps from 60/hr to 5000/hr.
#   - /user/starred needs auth to know whose stars to return.
#   - You can swap to /user/repos to include private repos.
app:
  title: GitHub (auth)
  theme: nord
  env:
    - name:        GITHUB_TOKEN
      required:    true
      description: "Classic PAT or fine-grained token with repo:read scope. https://github.com/settings/tokens"

data:
  sources:
    starred:
      type: http
      url: https://api.github.com/user/starred?per_page=50&sort=updated
      refresh: 10m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json
        X-GitHub-Api-Version: "2022-11-28"
        Authorization: "Bearer ${env.GITHUB_TOKEN}"

    repo_detail:
      type: http
      url: https://api.github.com/repos/${selection.Repo}
      refresh: 10m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json
        X-GitHub-Api-Version: "2022-11-28"
        Authorization: "Bearer ${env.GITHUB_TOKEN}"

tui:
  components:
    starred_table:
      type: table
      title: ${env.USER}'s starred repos
      filterable: true
      source: starred
      initial_sort: {column: Stars, desc: true}
      columns:
        - {title: Repo,        width: 32, sortable: true,                       value: full_name}
        - {title: Language,    width: 14, sortable: true,                       value: language}
        - {title: Stars,       width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}
        - {title: Updated,     width: 22, sortable: true,                       value: updated_at}
        - {title: Description, width: 40,                                       value: description}

    repo_inspector:
      type: inspector
      title: ${selection.Repo}
      filterable: true
      initial_depth: 2
      source: repo_detail
      fields:
        - {label: Full name,      path: full_name}
        - {label: Description,    path: description}
        - {label: Homepage,       path: homepage}
        - {label: Default branch, path: default_branch}
        - {label: License,        path: "license.spdx_id"}
        - {label: Language,       path: language}
        - {label: Visibility,     path: visibility}
        - label: Stats
          children:
            - {label: Stars,       path: stargazers_count}
            - {label: Watchers,    path: subscribers_count}
            - {label: Forks,       path: forks_count}
            - {label: Open issues, path: open_issues_count}
            - {label: Size (KB),   path: size}
        - label: Timestamps
          children:
            - {label: Created, path: created_at}
            - {label: Updated, path: updated_at}
            - {label: Pushed,  path: pushed_at}
        - label: Owner
          children:
            - {label: Login,     path: "owner.login"}
            - {label: Type,      path: "owner.type"}
            - {label: HTML URL,  path: "owner.html_url"}
  screens:
    starred:
      title: Starred
      layout: {component: starred_table}
      actions:
        - {key: enter, name: open_detail, push: detail, from: starred_table}

    detail:
      title: ${selection.Repo}
      layout: {component: repo_inspector}
  initial: starred

exec_local

An exec source: git log as a table.

task example NAME=exec_local
exec_local.yaml
# exec_local.yaml — data source that runs a subprocess and parses its
# stdout as JSON. Same component-binding language as http; just a
# different way to acquire the bytes.
#
# Try it: every CLI you have that emits JSON becomes a tui-builder
# source. `kubectl get … -o json`, `gh api`, `aws … --output json`,
# `terraform output -json`, custom scripts, whatever.
#
# This example calls `git log` once on screen activate to render the
# repo's last 20 commits — no network, no auth, works in this repo.
app:
  title: Recent commits (exec)
  theme: nord

data:
  sources:
    commits:
      type: exec
      # The format the YAML schema cares about: an argv list. First entry
      # is the binary (PATH-searched); the rest are passed as-is. No
      # shell — quoting / globbing happens here, not in /bin/sh.
      command:
        - sh
        - -c
        - >
          git log -n 20 --pretty=format:'%H%x09%h%x09%an%x09%ae%x09%aI%x09%s'
          | awk -F'\t' 'BEGIN{print "["} NR>1{print ","} {gsub(/"/, "\\\"", $6); printf "{\"hash\":\"%s\",\"short\":\"%s\",\"author\":\"%s\",\"email\":\"%s\",\"date\":\"%s\",\"subject\":\"%s\"}", $1,$2,$3,$4,$5,$6} END{print "]"}'
      # Optional: extra env on top of the inherited process env.
      # env:
      #   GIT_PAGER: cat
      timeout: 10s

tui:
      # No refresh: one fetch on screen entry. Press `r` to re-run.

  components:
    commits_table:
      type: table
      title: Recent commits
      filterable: true
      source: commits
      initial_sort: {column: Date, desc: true}
      columns:
        - {title: SHA,     width: 9,  value: short}
        - {title: Date,    width: 22, sortable: true, value: date}
        - {title: Author,  width: 22, sortable: true, value: author}
        - {title: Subject, width: 60, value: subject}
  screen:
    title: Commits
    layout:
      component: commits_table

file_fixture

A file source re-read every 5s; edit the file and watch the table change.

task example NAME=file_fixture
file_fixture.yaml
# file_fixture.yaml — read a JSON file off disk. Useful for fixtures,
# offline demos, and any "I generated this nightly" workflow.
#
# `refresh: 5s` re-reads the file on every tick so you can edit the
# fixture in another window and watch the table update. With `refresh:`
# unset, the file is read once on screen entry; press `r` to re-read.
app:
  title: Fixture file
  theme: nord

data:
  sources:
    fixture:
      type: file
      path: ./examples/fixtures/people.json
      root: people
      refresh: 5s

tui:
  components:
    people_table:
      type: table
      title: People
      filterable: true
      source: fixture
      initial_sort: {column: Name, desc: false}
      columns:
        - {title: Name,    width: 18, sortable: true, value: name}
        - {title: Role,    width: 16, sortable: true, value: role}
        - {title: Team,    width: 14, sortable: true, value: team}
        - {title: Joined,  width: 12, sortable: true, value: joined}
  screen:
    title: People
    layout:
      component: people_table

merge_sources

A merge of a file and two commands, tagged by source.

task example NAME=merge_sources
merge_sources.yaml
# merge_sources.yaml — fan out to N children, union the rows into one
# table. Each row gets tagged with which child it came from so you can
# filter / sort / group by source.
#
# This example merges three heterogeneous people sources — a local file
# fixture, a synthesized "fresh" subprocess (echoes inline JSON), and a
# tiny third group — into one searchable table. The same primitive
# composes across clusters / accounts / regions / environments — give
# `merge` the source names, set `tag_field`, point a table at the
# composed source.
#
# `on_error: skip` keeps the data flowing when one child fails (only
# errors when every child fails). Default is `on_error: fail`.
app:
  title: Merged people directory
  theme: nord

data:
  sources:
    # Local file fixture (existing).
    people_a:
      type: file
      path: ./examples/fixtures/people.json
      root: people

    # Subprocess that prints a small JSON array — stands in for a CLI
    # like `aws … describe-… --output json` or a custom script.
    people_b:
      type: exec
      command:
        - sh
        - -c
        - |
          cat <<'JSON'
          [
            {"name":"Vint Cerf",       "role":"Architect", "team":"Networking", "joined":"2019-10-01"},
            {"name":"Tim Berners-Lee", "role":"Director",  "team":"Web",        "joined":"2018-05-12"},
            {"name":"Radia Perlman",   "role":"Engineer",  "team":"Networking", "joined":"2020-07-09"}
          ]
          JSON

    # Inline (via another exec) — could just as easily be another file
    # source or an http source.
    people_c:
      type: exec
      command:
        - sh
        - -c
        - |
          echo '[{"name":"Frances Allen","role":"Researcher","team":"Compilers","joined":"2017-02-14"}]'

    # The composer. Children are fetched concurrently every refresh;
    # results are unioned in cfg.Sources order. Each map-shaped item gets
    # the originating child's name written under the default `_meta` key
    # (so framework metadata stays segregated from the upstream people
    # fields). Downstream column / inspector paths reach it via
    # `_meta.source`. Set `meta_key: ""` to opt out and write flat.
    people_all:
      type: merge
      sources: [people_a, people_b, people_c]
      tag_field: source
      on_error: skip
      refresh: 30s

tui:
  components:
    all_people:
      type: table
      title: All people (merged)
      filterable: true
      source: people_all
      initial_sort: {column: Joined, desc: true}
      columns:
        # The injected tag — column shows which child the row came from.
        # Lives under `_meta` (the default segregation key), so the path
        # is `_meta.source`, not bare `source`.
        - title: Source
          width: 12
          sortable: true
          value: _meta.source
          color_rules:
            - {when: people_a, color: theme:accent}
            - {when: people_b, color: green}
            - {when: people_c, color: yellow}
        - {title: Name,   width: 22, sortable: true, value: name}
        - {title: Role,   width: 14, sortable: true, value: role}
        - {title: Team,   width: 16, sortable: true, value: team}
        - {title: Joined, width: 12, sortable: true, value: joined}
  screen:
    title: People (merged)
    layout:
      component: all_people

params_demo

A source with parameters:, bound with wrangl --param or a push.

task example NAME=params_demo
params_demo.yaml
# params_demo.yaml — parameterized HTTP source via the new `parameters:`
# block.
#
# The point of this example is the schema, not the data. Each source can
# declare typed inputs that callers (wrangl --param, TUI push site,
# pipeline operators) must supply before the source can run. Inputs are
# referenced in URL / header / body templates as ${params.<name>} — same
# substitution machinery as ${env.*}.
#
# Try it:
#
#   # required param missing → clear error
#   task wrangl -- examples/params_demo.yaml posts_by_user
#
#   # required param supplied; optional `limit` falls back to default
#   task wrangl -- examples/params_demo.yaml posts_by_user --param user_id=1
#
#   # both params supplied
#   task wrangl -- examples/params_demo.yaml posts_by_user --param user_id=3 --param limit=10
#
# Public API: jsonplaceholder.typicode.com — no auth, stable, 10 users.
app:
  title: Params Demo
data:
  sources:
    posts_by_user:
      type: http
      # parameters declares the source's required and optional inputs.
      # Each entry is a typed slot; callers bind values, sources reference
      # them via ${params.<name>}.
      parameters:
        user_id:
          type: int
          required: true
          description: jsonplaceholder user id (1-10)
        limit:
          type: int
          default: 5
          description: maximum posts to return
      url: https://jsonplaceholder.typicode.com/users/${params.user_id}/posts?_limit=${params.limit}
      timeout: 10s
    user_posts:
      type: passthrough
      from: posts_by_user
tui:
  # Minimal table so the YAML validates as a complete tui-builder config.
  # wrangl ignores components / screen entirely; this only matters if you
  # point tui-builder at the file. For TUI use, params would need to be
  # bound from env (`from_env:`) or from a boot prompt — not part of this
  # POC. wrangl is the focal point.
  components:
    posts:
      type: table
      title: Posts
      columns:
        - {title: ID, width: 6, sortable: true, value: id}
        - {title: User, width: 6, sortable: true, value: userId}
        - {title: Title, flex: 2, value: title}
      source: user_posts
  screen:
    layout:
      component: posts

Paging

http_paginated

paginate: walks every page up front into one list.

task example NAME=http_paginated
http_paginated.yaml
# http_paginated.yaml — walk a paginated REST API in one Fetch.
#
# The public JSONPlaceholder API returns 100 photos across pages of 30
# (with `?_page=N&_limit=30`) and links `Link: <...>; rel="next"` in
# the response header. It doesn't quite fit the `link` strategy (which
# reads the `next` URL from the response BODY, Django REST style), but
# it makes a self-contained demo when we substitute a body-carrying
# stub in tests.
#
# For a real-world Django REST / AWX / GitLab endpoint that ships the
# next URL in the body:
#
#   sources:
#     awx_jobs:
#       type: http
#       url: ${env.AWX_HOST}/api/v2/jobs/?order_by=-created
#       headers: {Authorization: "Bearer ${env.AWX_TOKEN}"}
#       root: results
#       paginate:
#         strategy: link          # follow response.next
#         next_path: next
#         max_pages: 20           # safety cap; ~4000 jobs at page_size=200
#
# Semantics:
#   - Fetch the URL, parse JSON, extract items via `root:`.
#   - Look up the `next_path` in the RAW response (pre-root) to find
#     the next page's URL. Missing / null / empty string = we're done.
#   - Concatenate all pages' items into one flat list; hand back.
#   - Mutually exclusive with `follow: true` (streaming) and
#     `format: text` (needs JSON to find the next link).
#   - `on_page_error: skip` returns what we have on a mid-walk error
#     instead of throwing everything away.
#
# Try it:
#
#   task wrangl -- examples/http_paginated.yaml photos_all | jq 'length'

app:
  title: Paginated fetch demo

data:
  sources:
    # Same public API as http_countries.yaml, but sized so pagination
    # actually matters. JSONPlaceholder doesn't carry a body-next
    # link — this entry works as a config-load smoke test but a real
    # fetch will just return one page's worth. Point at a Django REST
    # / AWX endpoint to see the walk in action.
    photos_all:
      type: http
      url: https://jsonplaceholder.typicode.com/photos
      paginate:
        strategy: link
        next_path: next   # JSONPlaceholder returns a flat array, so
                          # this lookup returns nil and the walk stops
                          # after page 1. Real APIs carry a URL.
        max_pages: 5

tui:
  components:
    photos:
      type: table
      title: Photos
      source: photos_all
      columns:
        - {title: ID,     width: 6,  value: id,        sort: number}
        - {title: Album,  width: 6,  value: albumId,   sort: number}
        - {title: Title,  width: 60, value: title}
      filterable: true
  screen:
    layout:
      component: photos

http_window

window: over http: the server pages and filters across millions of rows while the table holds a hundred.

task example NAME=http_window
http_window.yaml
# http_window.yaml — filter and page a remote API from the table.
#
# The Open Library search API holds a few million books and answers
# `?offset=&limit=` for paging, `?q=` / `?author=` / `?title=` for
# searching, and reports the match count as `numFound`. That's every
# ingredient `window:` needs, with no key and no signup.
#
# Contrast with examples/http_paginated.yaml, the *other* way to handle a
# multi-page API:
#
#   paginate:  walk every page up front, hand back one flat list. The
#              table then filters and sorts what it holds. Great up to a
#              few thousand rows; 300 requests before the first frame at
#              30,000.
#
#   window:    fetch only the rows on screen, and let the server answer
#              the filter and the sort. One request for the first 100
#              rows, one more each time the user scrolls past what's
#              loaded — and "author:tolkien" searches all 2 million
#              books, not the 100 that happen to be resident.
#
# What to try once it's up:
#
#   /                     open the filter
#   hobbit                bare term  → ?q=hobbit          (searches everything)
#   author:tolkien        scoped     → ?author=tolkien    (mapped in filters:)
#   author:tolkien ring   both       → ?author=tolkien&q=ring
#   enter                 commits — the request goes out here, not per keystroke
#   G                     jump to the last row; watch "·" placeholders fill in
#   s / [ ]               sort by Editions (see the note on sort_param below)
#   r                     refetch the window on screen
#
# Scroll fast and you'll see rows of "·" for a moment. That's the point:
# the table is showing you 2 million logical rows while holding 100.
#
# Headless check (fetches the first window, prints it as JSON):
#
#   task wrangl -- examples/http_window.yaml books | jq 'length'

app:
  title: Open Library

data:
  sources:
    books:
      type: http
      # Query params pinned here survive into every windowed request —
      # only the window's own params get set. So `fields` trims the
      # response on every page, and `q=tolkien` is the *default* search
      # that a filter the user types replaces (search_param is `q` too).
      url: https://openlibrary.org/search.json?q=tolkien&fields=title,author_name,first_publish_year,edition_count
      root: docs

      window:
        page_size: 100
        # One page of lookahead: without it the user sees placeholder
        # rows for a beat at every 100-row boundary. With it, the next
        # page is usually already in hand — at the cost of one extra
        # request per boundary.
        prefetch: 1

        offset_param: offset
        limit_param: limit
        # Where the match count lives in the RAW response (before
        # `root: docs` slices out the items). This is what lets the
        # table size its scrollbar against 2,060 rows while holding 100.
        # Drop it and the table just treats the end of what's loaded as
        # the end — still correct, less informative.
        total_path: numFound

        # Bare terms — anything typed without a "column:" prefix — go
        # here. Open Library's `q` searches title, author, subject, and
        # full text at once.
        search_param: q
        # Scoped terms: the key is the COLUMN TITLE as it appears in
        # columns[].title below, the value is the query parameter. So
        # typing "author:tolkien" sends ?author=tolkien. A scoped term on
        # a column that isn't listed here falls back to `q`.
        filters:
          Title: title
          Author: author

        sort_param: sort
        sorts:
          # Open Library's sort tokens bake in their direction — `new` is
          # newest-first, `old` is oldest-first, `editions` is
          # most-editions-first. There's no ascending form of `editions`,
          # so ▲ and ▼ both send the same thing here and only the marker
          # flips. That's this API, not the schema.
          #
          # An API with a symmetric sort (Django REST, most of what
          # copies it) gets both directions from one line:
          #
          #   sort_param: ordering
          #   sort_desc_prefix: "-"     # ascending: ?ordering=year
          #                             # descending: ?ordering=-year
          Editions: editions

tui:
  components:
    books:
      type: table
      title: Books
      source: books
      # A windowed table filters and sorts remotely — it can't do either
      # locally, because the rows it holds are one page of a much larger
      # answer. That switch is automatic: binding to a source with
      # `window:` is what flips it, there's no second flag to set.
      filterable: true
      filter_placeholder: "hobbit · author:tolkien · title:ring"
      columns:
        # Fixed and flex widths only. A content-auto column sizes itself
        # to the widest cell it can see, so columns would reflow every
        # time a new window landed underneath the cursor.
        - {title: Title,    width: 40, flex: 3, value: title}
        - {title: Author,   width: 24, flex: 1, value: author_name.0}
        - {title: Year,     width: 6,  value: first_publish_year, align: right}
        - {title: Editions, width: 9,  value: edition_count, align: right, sortable: true}
  screen:
    layout:
      component: books

exec_window

window: over exec: git log --skip/--max-count/--author/--grep answers the table's paging and filtering.

task example NAME=exec_window
exec_window.yaml
# exec_window.yaml — page and filter a *command* from the table.
#
# The same windowing contract as examples/http_window.yaml, but the
# request goes into an argv instead of a query string. `git log` happens
# to speak it natively:
#
#   --skip=N        offset
#   --max-count=N   limit
#   --author=X      a scoped filter
#   --grep=X        a bare search
#
# ...so the command below is a thin shell wrapper that turns one window
# request into one `git log` invocation, plus a `git rev-list --count`
# for the total.
#
# The point isn't git. It's that anything with paging flags works the
# same way:
#
#   psql -tAc "SELECT … LIMIT ${window.limit} OFFSET ${window.offset}"
#   aws ec2 describe-instances --max-items ${window.limit} …
#   gh api "/search/issues?per_page=${window.limit}&page=…"
#   ./my-report --offset=${window.offset} --limit=${window.limit}
#
# What to try:
#
#   /                    open the filter
#   fix                  bare term  → --grep=fix       (searches all commits)
#   author:john          scoped     → --author=john    (mapped in filters:)
#   author:john fix      both flags at once
#   enter                commits — the command runs here, not per keystroke
#   G                    jump to the oldest commit; watch windows swap
#   r                    re-run the command for the window on screen
#
# Run it against a big repo to see the real point — in a clone of the
# Linux kernel this pages through 1.3M commits holding 5 at a time.
#
# Headless check (runs the first window, prints it as JSON):
#
#   task wrangl -- examples/exec_window.yaml commits | jq 'length'

app:
  title: Commit log (windowed exec)

data:
  sources:
    commits:
      type: exec
      root: rows

      # Window tokens substitute BEFORE the shell runs, so `sh` never
      # sees `${window.*}` — by the time it starts, these are literals
      # like `--skip=0`. Ordinary shell variables ($ARG, $TOTAL) are
      # untouched: only the `${window.…}` namespace is substituted.
      #
      # Quoting note: substitution is literal, exactly like ${params.*}.
      # A filter value containing a quote or a `$` lands verbatim in this
      # shell string. That's tolerable here because the only person who
      # can type in the filter bar is the person already running the
      # command — but prefer plain argv (no `sh -c`) when you can, so
      # the value is passed as a single arg and never parsed by a shell.
      command:
        - sh
        - -c
        - |
          AUTHOR="${window.filters.author}"
          SEARCH="${window.search}"
          ARGS=""
          [ -n "$AUTHOR" ] && ARGS="$ARGS --author=$AUTHOR"
          [ -n "$SEARCH" ] && ARGS="$ARGS --grep=$SEARCH"

          # The total has to reflect the SAME filter the rows do, or the
          # table sizes its scrollbar against the wrong set and lets the
          # cursor run off the end of the results.
          TOTAL=$(git rev-list --count $ARGS HEAD)

          ROWS=$(git log $ARGS --skip=${window.offset} --max-count=${window.limit} \
                   --pretty=format:'%h%x09%an%x09%ad%x09%s' --date=short \
                 | awk -F'\t' 'NR>1{printf ","}
                     {gsub(/\\/,"\\\\",$4); gsub(/"/,"\\\"",$4);
                      printf "{\"hash\":\"%s\",\"author\":\"%s\",\"date\":\"%s\",\"subject\":\"%s\"}",$1,$2,$3,$4}')

          printf '{"total":%s,"rows":[%s]}' "$TOTAL" "$ROWS"

      window:
        # Deliberately tiny so windows actually swap in a small repo.
        # Use 100+ against anything real.
        page_size: 5
        prefetch: 1

        # Where the filtered commit count lives in the RAW stdout,
        # before `root: rows` slices out the items.
        total_path: total

        # Note what ISN'T here: no offset_param / limit_param /
        # search_param / sort_param. Those name query-string parameters
        # and mean nothing to an argv — an exec window templates the
        # request into `command:` instead, and validation rejects them
        # rather than ignoring them.
        #
        # filters: maps a COLUMN TITLE to the token that carries it, so
        # typing "author:john" against the Author column below reaches
        # the command as ${window.filters.author}. The user can type any
        # unambiguous prefix — "auth:john" resolves to Author just the
        # same, and arrives here as the full title.
        filters:
          Author: author

tui:
  components:
    commits:
      type: table
      title: Commits
      source: commits
      # Automatic, because the bound source declares `window:` — the
      # filter goes to the command, not to the five rows on screen.
      filterable: true
      filter_placeholder: "fix · author:john · author:john fix"
      columns:
        # Fixed and flex widths only — a content-auto column would
        # reflow every time a window landed.
        - {title: Hash,    width: 9,  value: hash}
        - {title: Author,  width: 20, value: author}
        - {title: Date,    width: 10, value: date}
        - {title: Subject, width: 40, flex: 1, value: subject}
      # No sortable columns: `git log` orders by history, and there's no
      # ${window.sort} plumbing in the command above. A command that DID
      # take an order flag would reference ${window.sort} and
      # ${window.sort_dir} and mark its columns sortable.
  screen:
    layout:
      component: commits

Event logs and Anchored sources

eventlog

An eventlog paging a 2,000-event job log through an exec window: search what's loaded, filter on the "server", enter to open an event.

task example NAME=eventlog
eventlog.yaml
# eventlog.yaml — a long job log read a page at a time.
#
# An eventlog shows events or lines from a source too large to hold, the
# way you'd read an AWX job's events or a search index: it asks the source
# for the window you're looking at and fetches more as you scroll. Each
# item draws as the lines of its `text:`; an item with no text still shows,
# as "· no output", because it still happened.
#
# The source here is a script that pages a synthetic 2,000-event,
# AWX-style job log, so the example runs anywhere.
#
# Try:
#   - scroll, PgDn, `g` / `G` (top / newest)
#   - `/` to search what's loaded, `n` / `N` for the next match
#   - `f` to filter: the text goes to the script as ${window.search}, so
#     `fatal` narrows all 2,000 events, not just the ones on screen
#   - enter on an event to open its raw fields; esc to close
#
# A real job is still running while you watch it. Declare that with
# `window.growing:` and the eventlog follows the newest event, counting
# new ones as "↓ N new" when you scroll away:
#
#   job:
#     type: http
#     url: ${env.AWX_HOST}/api/v2/jobs/${params.id}/
#     refresh: 5s
#   job_events:
#     ...
#     window:
#       growing: {source: job, while: 'status in ["pending", "waiting", "running"]'}
#       follow_every: 2s
app:
  title: Job log
  theme: nord
data:
  sources:
    job_events:
      type: exec
      root: results
      command:
        - sh
        - -c
        - |
          awk -v off="${window.offset}" -v lim="${window.limit}" -v q="${window.search}" 'BEGIN {
            split("ok changed skipping", st, " "); n = 0; out = ""
            stars = ""; for (k = 0; k < 40; k++) stars = stars "*"
            for (i = 1; i <= 2000; i++) {
              host = "web" (i % 5 + 1)
              if (i % 50 == 1)      { ev = "playbook_on_task_start"; txt = "TASK [step " int(i / 50) "] " stars }
              else if (i % 7 == 0)  { ev = "runner_on_start"; txt = "" }
              else if (i % 97 == 0) { ev = "runner_on_failed"; txt = "fatal: [" host "]: FAILED! => {\"msg\": \"timeout\"}\nretrying in 5s" }
              else                  { s = st[i % 3 + 1]; ev = "runner_on_" s; txt = s ": [" host "]" }
              if (q != "" && index(tolower(txt), tolower(q)) == 0) continue
              if (n >= off && n < off + lim) {
                gsub(/\\/, "\\\\", txt); gsub(/"/, "\\\"", txt); gsub(/\n/, "\\n", txt)
                out = out (out == "" ? "" : ",") sprintf("{\"id\":%d,\"counter\":%d,\"event\":\"%s\",\"created\":\"10:%02d:%02d\",\"stdout\":\"%s\",\"event_data\":{\"host\":\"%s\"}}", 4000 + i, i, ev, int(i / 60) % 60, i % 60, txt, host)
              }
              n++
            }
            printf "{\"count\":%d,\"results\":[%s]}", n, out
          }'
      window:
        page_size: 100
        prefetch: 1
        total_path: count
tui:
  components:
    events:
      type: eventlog
      title: Job events
      source: job_events
      key: id
      text: stdout
      mark: created
      searchable: true
      filterable: true
      color_rules:
        - {when: "~^fatal", color: red}
        - {when: "~^changed", color: yellow}
        - {when: "~^TASK", color: cyan}
  screen:
    title: Job events
    layout:
      component: events

anchored

An eventlog over an Anchored source: a log walked by timestamp with window.cursor: (the search_after shape), opening at the newest line.

task example NAME=anchored
anchored.yaml
# anchored.yaml — a log walked by timestamp instead of paged by offset.
#
# Some APIs can't jump to row N: Elasticsearch past 10,000 hits, log APIs
# filtered by "before / after this timestamp", anything that pages with an
# ?after=<token>. They're Anchored: you read them by walking from an edge.
# `window.cursor:` declares that, naming the field each request walks from.
#
# This one is a script over a synthetic 1,000-line log, two lines per
# second, so the example runs anywhere. It opens at the newest line;
# scroll up (or `g`) and it asks for the lines before the oldest it holds.
# Two lines share every timestamp: they're kept apart by `key:`, so
# neither is lost at a page boundary.
#
# The same shape over Elasticsearch, with the cursor in the POST body:
#
#   logs:
#     type: http
#     method: POST
#     url: ${env.ES_URL}/app-logs/_search
#     root: hits.hits
#     body: '{"query": {"match_all": {}}, "size": ${window.limit}}'
#     window:
#       cursor: sort
#       older: {sort: [{"@timestamp": desc}, {_id: desc}], search_after: "${window.cursor}"}
#       newer: {sort: [{"@timestamp": asc},  {_id: asc}],  search_after: "${window.cursor}"}
app:
  title: App log
  theme: nord
data:
  sources:
    app_log:
      type: exec
      command:
        - sh
        - -c
        - |
          # ${window.dir} is older or newer; ${window.cursor} is the edge
          # line's timestamp (empty on the first request); ${window.limit}
          # is one more than a page. An older walk answers newest first.
          awk -v dir="${window.dir}" -v cur="${window.cursor}" -v lim="${window.limit}" -v q="${window.search}" 'BEGIN {
            split("INFO INFO INFO WARN INFO ERROR", lvl, " "); n = 0; out = ""
            if (dir == "older") { from = 999; to = 0; step = -1 } else { from = 0; to = 999; step = 1 }
            for (i = from; (step > 0 ? i <= to : i >= to); i += step) {
              ts = int(i / 2)
              if (cur != "" && ((dir == "older" && ts >= cur) || (dir == "newer" && ts <= cur))) continue
              level = lvl[i % 6 + 1]
              msg = sprintf("%s request %d served in %dms", level, i, (i * 37) % 900)
              if (q != "" && index(tolower(msg), tolower(q)) == 0) continue
              if (n >= lim) break
              out = out (out == "" ? "" : ",") sprintf("{\"id\":\"req-%04d\",\"ts\":%d,\"at\":\"12:%02d:%02d\",\"level\":\"%s\",\"msg\":\"%s\"}", i, ts, int(ts / 60) % 60, ts % 60, level, msg)
              n++
            }
            printf "[%s]", out
          }'
      window:
        page_size: 50
        cursor: ts
tui:
  components:
    log:
      type: eventlog
      title: App log
      source: app_log
      key: id
      text: msg
      mark: at
      searchable: true
      filterable: true
      color_rules:
        - {when: "~^ERROR", color: red}
        - {when: "~^WARN", color: yellow}
  screen:
    title: App log
    layout:
      component: log

span_table

A span table over the same Anchored log: rows walked from the newest, marks that hold as older pages load, and a multi action over them.

task example NAME=span_table
span_table.yaml
# span_table.yaml — a table over an Anchored source: a span table.
#
# The same synthetic log as anchored.yaml, as rows and columns. A table
# bound to a source with `window.cursor:` holds a run of rows walked from
# an edge: oldest at the top, newest at the bottom, opening at the newest
# and growing as you scroll up (or `g`). It's trimmed past `max_items`.
#
# Rows are keyed by their cursor and `mark_key:`, so marks stay on their
# rows as more arrive: mark a few with `x`, scroll up so older pages load,
# then open the menu with `a` and run "show marked" over all of them.
#
# The order is the walk's, so columns can't be sorted; `/` filters on the
# "server" (the script) and re-walks from the newest matching row.
app:
  title: Requests
  theme: nord
data:
  sources:
    requests:
      type: exec
      command:
        - sh
        - -c
        - |
          # ${window.dir} is older or newer; ${window.cursor} is the edge
          # line's timestamp (empty on the first request); ${window.limit}
          # is one more than a page. An older walk answers newest first.
          awk -v dir="${window.dir}" -v cur="${window.cursor}" -v lim="${window.limit}" -v q="${window.search}" 'BEGIN {
            split("INFO INFO INFO WARN INFO ERROR", lvl, " "); n = 0; out = ""
            if (dir == "older") { from = 999; to = 0; step = -1 } else { from = 0; to = 999; step = 1 }
            for (i = from; (step > 0 ? i <= to : i >= to); i += step) {
              ts = int(i / 2)
              if (cur != "" && ((dir == "older" && ts >= cur) || (dir == "newer" && ts <= cur))) continue
              level = lvl[i % 6 + 1]
              msg = sprintf("%s request %d served in %dms", level, i, (i * 37) % 900)
              if (q != "" && index(tolower(msg), tolower(q)) == 0) continue
              if (n >= lim) break
              out = out (out == "" ? "" : ",") sprintf("{\"id\":\"req-%04d\",\"ts\":%d,\"at\":\"12:%02d:%02d\",\"level\":\"%s\",\"msg\":\"%s\"}", i, ts, int(ts / 60) % 60, ts % 60, level, msg)
              n++
            }
            printf "[%s]", out
          }'
      window:
        page_size: 50
        cursor: ts
tui:
  components:
    requests_table:
      type: table
      title: Requests
      source: requests
      mark_key: id
      markable: true
      filterable: true
      max_items: 400
      columns:
        - {title: At, value: at, width: 9}
        - title: Level
          value: level
          width: 6
          color_rules:
            - {when: ERROR, color: red}
            - {when: WARN, color: yellow}
        - {title: Message, value: msg, flex: 1}
        - {title: Id, value: id, hidden: true}
  screen:
    title: Requests
    layout:
      component: requests_table
    actions:
      - action: show_marked
        label: show marked
        from: requests_table
        bind: {id: "${selection.Id}"}
actions:
  show_marked:
    description: Print a request id (fans out over marked rows)
    multi: true
    run: [echo, "${inputs.id}"]
    inputs:
      id: {required: true}

Streaming

stream_exec

exec with follow: true streaming into a logview.

task example NAME=stream_exec
stream_exec.yaml
# stream_exec.yaml — exec source in follow mode → logview.
#
# `follow: true` switches exec from one-shot capture to a long-running
# stream: the subprocess is started and its stdout is read line-by-line
# as it emits. Each line becomes an Event delivered to the bound
# logview, which Append's it without resetting cursor / filter / scroll.
#
# Replace the command with anything that streams to stdout:
#   - kubectl logs -f pod-name
#   - tail -f /var/log/system.log
#   - journalctl -f
#   - docker logs -f container
#   - your own script
#
# This example synthesizes ticks via /bin/sh so the demo works without
# extra setup. Try it as a model for wiring a real follow command.
app:
  title: Streaming exec demo
  theme: nord

data:
  sources:
    ticker:
      type: exec
      follow: true
      command:
        - sh
        - -c
        # Emit one line per second forever. The `i=$((i+1)); echo …; sleep 1`
        # loop is the lowest-common-denominator stand-in for `kubectl logs -f`.
        - 'i=0; while true; do i=$((i+1)); printf "%s tick %d\n" "$(date +%H:%M:%S)" "$i"; sleep 1; done'

tui:
  components:
    stream:
      type: logview
      title: Live stdout (follow mode)
      searchable: true
      source: ticker
      # color_rules apply to streaming lines as they arrive — useful for
      # log-level coloring without any client-side transformation.
      color_rules:
        - {when: "~tick [0-9]*[05]$", color: theme:accent}   # every 5th tick
        - {when: "~^",                 color: theme:bar-fg}
  screen:
    title: Stream
    layout:
      component: stream

stream_websocket

A websocket source streaming into a logview.

task example NAME=stream_websocket
stream_websocket.yaml
# stream_websocket.yaml — websocket source → logview, live BTC/USD trades.
#
# Many real-world WebSocket APIs (bitstamp, coinbase, kraken, custom
# event buses) won't start emitting until the client sends a subscribe
# message. `initial_messages:` is a list of text frames sent
# immediately after the connection upgrades. ${selection.*} and
# ${env.*} substitute per entry, so you can parameterize channel
# names, auth tokens, etc.
#
# This example subscribes to bitstamp's live_trades_btcusd channel.
#
# What you'll see in the logview (in order — every line appears as it
# happens; nothing blocks on the dial because Subscribe is async):
#   1. "(connecting to wss://ws.bitstamp.net)"
#      — emitted immediately on screen open; proves the TUI isn't frozen
#   2. "(connected to wss://ws.bitstamp.net; sent 1 initial message(s))"
#      — dial + subscribe write both succeeded; arrives ~1-3s after (1)
#   3. {"event":"bts:subscription_succeeded",...}
#      — bitstamp's confirmation of the subscribe
#   4. {"data":{...},"event":"trade",...}
#      — live trade frames, ongoing (several per second)
#
# If you see (1) but not (2), the dial is in flight or failed (a
# subsequent error event will arrive on failure — check the statusbar).
# If you see (1)+(2) but not (3), bitstamp accepted the subscribe but
# isn't emitting — likely a geo / network issue from your machine.
#
# Authentication: `headers:` apply on the initial HTTP upgrade request,
# so `Authorization: "Bearer ${env.TOKEN}"` etc. compose normally.
app:
  title: Streaming websocket demo (live BTC trades)
  theme: nord

data:
  sources:
    trades:
      type: websocket
      url: wss://ws.bitstamp.net
      timeout: 10s
      initial_messages:
        # bitstamp protocol: send a JSON subscribe frame to start the
        # data flow. The server begins emitting trade events on the
        # named channel immediately after.
        - '{"event":"bts:subscribe","data":{"channel":"live_trades_btcusd"}}'

tui:
  components:
    feed:
      type: logview
      title: Live BTC/USD trades
      searchable: true
      source: trades
      color_rules:
        # The synthetic "(connecting…)" / "(connected…)" diagnostic lines
        # in a calm color so they don't visually compete with real data.
        - {when: "~^\\(",                       color: theme:muted}
        # Buy-side / sell-side coloring via regex on the JSON payload.
        # bitstamp emits `"type":0` for buys and `"type":1` for sells.
        - {when: '~"type":\s*0',                color: green}
        - {when: '~"type":\s*1',                color: red}
        - {when: "~bts:subscription_succeeded", color: theme:accent}
  screen:
    title: Feed
    layout:
      component: feed

stream_trades_table

A websocket feeding a live table as a ring buffer (max_rows: 100).

task example NAME=stream_trades_table
stream_trades_table.yaml
# stream_trades_table.yaml — live BTC/USD trades streaming into a table.
#
# Each JSON frame from bitstamp's WebSocket becomes one table row:
#   - Frame parsed as JSON
#   - Cells projected via each column's `value:` dot-path
#   - Row prepended (newest-first) to the ring buffer
#   - Buffer trimmed to `max_rows`
#   - Table SetRows with the new buffer
#
# Frames that don't parse as JSON (the synthetic "(connecting…)" /
# "(connected…)" diagnostic lines emitted by the websocket source) and
# frames whose every projected cell is empty (the
# bts:subscription_succeeded confirmation) are silently skipped, so the
# table stays clean.
#
# Layout: vstack of two panes —
#   - small logview at the top tracks connection state + raw frames
#     (great for verifying everything's working)
#   - main table below shows the parsed trades
#
# Try: focus the table (tab), then sort by price (`]` to step the
# sort column, `s` to toggle direction). The buffer keeps trimming
# while you sort, but cursor + sort survive every refresh because
# tuilib's SetRows is in-place.
app:
  title: Live BTC/USD trades
  theme: nord

data:
  sources:
    trades:
      type: websocket
      url: wss://ws.bitstamp.net
      timeout: 10s
      initial_messages:
        - '{"event":"bts:subscribe","data":{"channel":"live_trades_btcusd"}}'

tui:
  components:
    raw_feed:
      type: logview
      title: Connection state + raw frames (debug)
      searchable: true
      source: trades
      color_rules:
        - {when: "~^\\(",                       color: theme:muted}
        - {when: "~bts:subscription_succeeded", color: theme:accent}
        - {when: '~"type":\s*0',                color: green}
        - {when: '~"type":\s*1',                color: red}

    trades_table:
      type: table
      title: Trades (newest first, ring buffer of 100)
      source: trades
      max_rows: 100
      columns:
        - {title: ID,        width: 12, value: "data.id"}
        - {title: Time,      width: 22, value: "data.timestamp"}
        - title: Side
          width: 6
          align: center
          value: "data.type"
          color_rules:
            - {when: "0", color: green}
            - {when: "1", color: red}
        - title: Price (USD)
          width: 16
          align: right
          sortable: true
          sort: number
          value: "data.price_str"
        - title: Amount (BTC)
          width: 16
          align: right
          sortable: true
          sort: number
          value: "data.amount_str"
  screen:
    title: Trades
    layout:
      vstack:
        - {fixed: 8, component: raw_feed}
        - {flex: 1,  component: trades_table}

stream_l1

Two streams merged by symbol into one keyed table with row_key:.

task example NAME=stream_l1
stream_l1.yaml
# stream_l1.yaml — L1 ticker built by JOINING two Binance streams.
#
# This example demonstrates four things stacked together:
#
#   1. WebSocket source (data type: websocket)
#   2. `merge` composing two STREAMING sources into one
#   3. `row_key` upsert-by-symbol on the resulting table
#   4. Deep merge: each source contributes different fields to the
#      same row, neither loses data when the other ticks
#
# Two streams:
#   - bookTicker  ── best bid / best ask + qty, ticks several × / sec
#   - @ticker     ── last-trade price, 24h % change, 24h volume + stats,
#                    ticks ~1× / sec
#
# Both keyed by symbol (`data.s`). Where they overlap (bid/ask), the
# bookTicker stream wins by virtue of being fresher (more frequent
# updates). Where they don't (price, change%, volume, high/low),
# bookTicker carries no info, so the ticker fields survive and the row
# accumulates a complete L1+stats view of each symbol.
#
# This is the same "compose N sources, key by row" pattern used for
# cross-cluster k8s in examples/kube_multi.yaml. There the sources are
# polled HTTP; here they're streaming WebSockets. Same `merge` source
# type, same row_key semantics — the difference is the wire protocol.
#
# Geography: binance.us URLs work in the US. International users:
# swap `binance.us` for `binance.com`.
app:
  title: Crypto L1 (bookTicker × @ticker merged)
  theme: nord

data:
  sources:
    # Stream 1: best bid / best ask, fast.
    book:
      type: websocket
      url: wss://stream.binance.us:9443/stream?streams=btcusdt@bookTicker/ethusdt@bookTicker/solusdt@bookTicker/adausdt@bookTicker/xrpusdt@bookTicker/dogeusdt@bookTicker/avaxusdt@bookTicker
      timeout: 10s

    # Stream 2: last price + 24h stats, slower (~1Hz / symbol).
    ticker:
      type: websocket
      url: wss://stream.binance.us:9443/stream?streams=btcusdt@ticker/ethusdt@ticker/solusdt@ticker/adausdt@ticker/xrpusdt@ticker/dogeusdt@ticker/avaxusdt@ticker
      timeout: 10s

    # The join. Merge subscribes to both children and fans every frame
    # into one channel — the consuming table doesn't have to know there
    # are two underlying connections.
    l1:
      type: merge
      sources: [book, ticker]

tui:
  components:
    status:
      type: logview
      title: Connection state (both streams)
      source: l1
      color_rules:
        - {when: "~^\\(",        color: theme:muted}    # synthetic diagnostics
        - {when: '~^\{"stream"', color: theme:subtle}   # constant chatter

    board:
      type: table
      title: L1 (one row per symbol; bid/ask from book + price/stats from ticker)
      source: l1
      row_key: "data.s"
      initial_sort: {column: Symbol, desc: false}
      columns:
        - {title: Symbol, width: 10, sortable: true, value: "data.s"}
        # Last price + 24h stats: only ticker carries these. They appear
        # ~1s after a row first becomes visible (when the first ticker
        # frame for that symbol arrives), then update at ~1Hz.
        - title: Price
          width: 14
          align: right
          sortable: true
          sort: number
          value: "data.c"
        - title: 24h %
          width: 9
          align: right
          sortable: true
          sort: number
          value: "data.P"
          color_rules:
            - {when: "<0",  color: red}
            - {when: ">0",  color: green}
            - {when: "==0", color: gray}
        # Best bid + size, best ask + size. Both streams carry all four
        # (b/B/a/A) — bookTicker wins because it arrives more often
        # (deep merge: latest writer wins field-by-field).
        - title: Best bid
          width: 14
          align: right
          sortable: true
          sort: number
          value: "data.b"
        - title: Bid qty
          width: 12
          align: right
          sortable: true
          sort: number
          value: "data.B"
        - title: Best ask
          width: 14
          align: right
          sortable: true
          sort: number
          value: "data.a"
        - title: Ask qty
          width: 12
          align: right
          sortable: true
          sort: number
          value: "data.A"
        # 24h volume / high / low: ticker only.
        - title: 24h vol
          width: 14
          align: right
          sortable: true
          sort: si
          value: "data.v"
        - title: 24h high
          width: 14
          align: right
          sortable: true
          sort: number
          value: "data.h"
        - title: 24h low
          width: 14
          align: right
          sortable: true
          sort: number
          value: "data.l"
  screen:
    title: L1
    layout:
      vstack:
        - {fixed: 8, component: status}
        - {flex: 1,  component: board}

Pipelines

filter_demo

The operator showroom: filter, project, derive, sort, union, join and pipeline parameters.

task example NAME=filter_demo
filter_demo.yaml
# filter_demo.yaml — pipeline operator showroom.
#
# Exercises every shipped operator against jsonplaceholder so each
# pattern has a runnable example you can `wrangl` and inspect with jq.
#
# Operators on display:
#   passthrough, filter, project, derive, sort, union, compose, join,
#   cache — plus pipeline parameters threaded through the operator
#   expressions.
#
# Expression language: internal/expr wraps expr-lang/expr. Available:
# `==` / `!=` / `<` / `>` / `<=` / `>=`, `&&` / `||` / `!`,
# `string contains substring` (infix), `matches` (regex infix),
# `string in []` (membership), arithmetic, plus functions
# (`lower`, `upper`, `len`, `hasPrefix`, `hasSuffix`, `indexOf`,
# `now`, `parseTime`). Bind pipeline params surface as `params.X`.
#
# Try it:
#
#   # all 10 users
#   task wrangl -- examples/filter_demo.yaml all_users
#
#   # only users in the 'Lannister' surname pattern (matches regex)
#   task wrangl -- examples/filter_demo.yaml users_with_long_username
#
#   # only users with website ending in .biz
#   task wrangl -- examples/filter_demo.yaml biz_users | jq '.[].website'
#
# Public API: jsonplaceholder.typicode.com — no auth, stable, 10 users.
app:
  title: Filter demo
data:
  sources:
    users:
      type: http
      url: https://jsonplaceholder.typicode.com/users
      timeout: 10s
    # Parameterized HTTP source used as a JOIN LOOKUP. Each driver row's
    # `id` field feeds into `user_id`, which substitutes into the URL.
    #
    # The `cache:` block opts this source into a params-aware LRU: the
    # join operator (and any future cursor-driven refetch) memoises by
    # (source, params-tuple), so N driver rows with the same user_id
    # produce one upstream fetch, not N. TTL bounds staleness; size
    # bounds memory. Omit `cache:` and the join uses defaults (60s /
    # ParamCacheDefaultSize=100) — automatic caching is on by default
    # for join lookups. Set `cache:` explicitly when you need to
    # override the defaults, or for other consumers (cursor-driven
    # detail panes) that don't get the automatic wrap.
    user_posts:
      type: http
      parameters:
        user_id: {type: int, required: true, description: "jsonplaceholder user id"}
      url: https://jsonplaceholder.typicode.com/users/${params.user_id}/posts
      timeout: 10s
      cache:
        ttl:  5m       # per (user_id → posts) freshness window
        size: 200      # cap LRU entries — evicts LRU when exceeded
    # Passthrough — same upstream, no transform. Lets components or wrangl
    # target a stable public name regardless of whether a filter sits on
    # top tomorrow.
    all_users:
      type: passthrough
      from: users
    # filter operator: keep users whose username is at least 8 characters.
    users_with_long_username:
      type: filter
      from: users
      where: "len(username) >= 10"
    # filter operator with regex: keep users whose website host ends in .biz.
    biz_users:
      type: filter
      from: users
      where: "hasSuffix(website, '.biz')"
    # project: slim each user down to a handful of named fields. Each
    # `keep:` value is an expression (bare dot-paths are the simple
    # case; `lower(name)` etc. work too).
    #
    #   task wrangl -- examples/filter_demo.yaml user_summary | jq '.[0]'
    user_summary:
      type: project
      from: users
      keep:
        id: id
        username: username
        domain: "lower(website)"
        city: "address.city"
    # derive: keep every original field AND add new ones computed from
    # the existing ones. Useful for synthesizing labels / flags without
    # losing access to the source data.
    #
    #   task wrangl -- examples/filter_demo.yaml users_with_labels | jq '.[0] | {name, is_biz, username_len}'
    users_with_labels:
      type: derive
      from: users
      compute:
        is_biz: "hasSuffix(website, '.biz')"
        username_len: "len(username)"
        upper_name: "upper(name)"
    # Chain: filter → project → emit a flat shape per surviving user.
    # Demonstrates pipeline-on-pipeline composition across operator
    # families.
    long_username_summary:
      type: project
      from: users_with_long_username
      keep:
        username: username
        name: name
    # sort: order users alphabetically by name (asc default).
    #
    #   task wrangl -- examples/filter_demo.yaml users_by_name | jq '[.[].name]'
    users_by_name:
      type: sort
      from: users
      by: name
    # sort + project: alphabetical user_summary. Chains the operators.
    #
    #   task wrangl -- examples/filter_demo.yaml summary_by_username
    summary_by_username:
      type: sort
      from: user_summary
      by: username
    # sort desc: largest companies (by catchPhrase length, just to
    # have a numeric key against this dataset).
    longest_catchphrases:
      type: sort
      from: users
      by: "len(company.catchPhrase)"
      order: desc
    # ── union examples ─────────────────────────────────────────────
    # union composes N iterables into one. Same semantics as the merge
    # SOURCE — including per-child tags nested under _meta — but lives
    # as a pipeline operator so children can be other PIPELINES, not
    # just leaf sources.
    #
    # Source-name shorthand (jsonplaceholder has only one users
    # endpoint, so we union the source with itself + a filtered version
    # of it to show the mechanics):
    #
    #   task wrangl -- examples/filter_demo.yaml all_groups | jq '.[]._meta.src'
    all_groups:
      type: union
      sources: [users, users_with_long_username]
      tag_field: src
    test_union:
      type: union
      sources: [users, users_with_long_username, user_summary]
      tag_field: src
    # The key new capability: union children can be FILTERS, PROJECTIONS,
    # DERIVES — anything. Merge as a source can only take other sources.
    # Here we union (a) all the .biz users with (b) all users named
    # alphabetically before "M", tagged so consumers can tell them apart.
    #
    #   task wrangl -- examples/filter_demo.yaml mixed | jq '[.[] | {name, group: ._meta.group}]'
    early_alpha_users:
      type: filter
      from: users
      where: "name < 'M'"
    mixed:
      type: union
      children:
        - source: biz_users
          tags: {group: "biz_websites"}
        - source: early_alpha_users
          tags: {group: "early_alpha"}
    # Combine: drop users without a company catchphrase containing 'synergy'.
    # Composable — this filter operates on the output of users_with_long_username,
    # not the raw users source.
    synergy_users:
      type: filter
      from: users_with_long_username
      where: "lower(company.catchPhrase) contains 'synergy'"
    # ── pipeline params ────────────────────────────────────────────
    # A pipeline can declare its OWN `parameters:` block. Bound values
    # are available in every operator expression as `params.<name>`
    # (separate from item fields, which stay at the top level).
    #
    # wrangl --param routing: if the target is a pipeline that declares
    # parameters, --param binds to the PIPELINE. Otherwise it falls
    # through to the underlying source (backwards-compatible).
    #
    #   # keep users whose username is at least N chars
    #   task wrangl -- examples/filter_demo.yaml long_usernames --param min=8
    #   task wrangl -- examples/filter_demo.yaml long_usernames --param min=12
    long_usernames:
      type: filter
      parameters:
        min: {type: int, default: "8", description: minimum username length}
      from: users
      where: "len(username) >= int(params.min)"
    # Same pattern, derive: parameterize the threshold for a label.
    #
    #   task wrangl -- examples/filter_demo.yaml labeled_by_name_len \
    #     --param threshold=12 | jq '[.[] | {username, long: .long_username}]'
    labeled_by_name_len:
      type: derive
      parameters:
        threshold: {type: int, default: "10"}
      from: users
      compute:
        long_username: "len(username) >= int(params.threshold)"
        username_len: "len(username)"
    # ── cache example ──────────────────────────────────────────────
    # cache memoises the upstream's snapshot for a TTL. The first read
    # hits the upstream; reads within the TTL return the cached value
    # without re-fetching. Errors aren't cached — failing upstreams
    # are retried on the next call.
    #
    # Useful when multiple downstream operators feed from the same
    # expensive upstream (e.g. a slow remote API). Below, both
    # `cached_users` consumers (`from_a`, `from_b`) hit the same
    # in-memory snapshot for 30 seconds.
    #
    #   task wrangl -- examples/filter_demo.yaml cached_users
    cached_users:
      type: cache
      from: users
      ttl: 30s
    # ── join example ───────────────────────────────────────────────
    # join takes a driver iterable and one or more lookups; for each
    # driver row it fetches the lookup with row-derived params, then
    # emits the enriched row. This is the "kubectl describe on a
    # selected row" pattern, expressed as a data-layer concept.
    #
    #   task wrangl -- examples/filter_demo.yaml users_with_posts | jq '[.[] | {name: .row.name, post_count: (.posts | length)}]'
    #
    # emit: separate (default) gives {row, posts} per driver row.
    # emit: merged would flatten posts into row, but jsonplaceholder
    # returns a list of post objects, so separate reads more cleanly.
    users_with_posts:
      type: join
      driver:
        from: users
      lookups:
        posts:
          from: user_posts
          on:
            user_id: id # expression evaluated against driver row
    # ── compose example ────────────────────────────────────────────
    # compose bundles N HETEROGENEOUS upstreams into a single object,
    # preserving each child's shape under a caller-chosen key. The output
    # is `{everyone: [...], biz: [...], top: [...]}` — three different
    # lists, three top-level keys, one addressable pipeline.
    #
    #   task wrangl -- examples/filter_demo.yaml fleet | jq 'keys'
    #   task wrangl -- examples/filter_demo.yaml fleet | jq '.biz | length'
    fleet:
      type: compose
      parts:
        everyone: users
        biz: biz_users
        top: longest_catchphrases
tui:
  components:
    table:
      type: table
      title: Users
      columns:
        - {title: ID, width: 6, sortable: true, value: id}
        - {title: Username, width: 16, sortable: true, value: username}
        - {title: Name, width: 26, sortable: true, value: name}
        - {title: Website, width: 22, value: website}
      source: all_users
  screen:
    layout:
      component: table

inline_pipe_demo

Inline pipe: chains on a source and on a pipeline.

task example NAME=inline_pipe_demo
inline_pipe_demo.yaml
# inline_pipe_demo.yaml — inline `pipe:` chains.
#
# A pipeline can declare a chain of single-input operators inline via
# `pipe:`, instead of breaking each step out as a separately-named
# pipeline. The output of each stage feeds the next; the top-level
# pipeline's value is the output of the last stage.
#
# When to use which:
#   - inline `pipe:` — the intermediate steps aren't interesting on
#     their own and you just want the final shape.
#   - separate named pipelines — the intermediate steps are
#     independently useful, or you want to share them across multiple
#     downstream consumers.
#
# Under the hood the loader desugars `pipe:` into a chain of anonymous
# pipelines named `_<owner>_step<N>`. They're hidden from `wrangl
# --list` by the leading-underscore convention (use `--list --all` to
# see them).
#
# Try it:
#
#   task wrangl -- examples/inline_pipe_demo.yaml short_summary | jq '.[0]'
#   task wrangl -- examples/inline_pipe_demo.yaml --list
#   task wrangl -- examples/inline_pipe_demo.yaml --list --all
#
# Restrictions:
#   - Stages are single-input operators only: filter, project, derive,
#     sort, cache. Multi-input ops (union, compose, join) take
#     additional upstreams that don't fit the implicit-previous-step
#     model — declare those as named pipelines and feed them with
#     `from:`.
#   - `from:` on the stage operator is forbidden — input is implicit.

app:
  title: Inline pipe demo
data:
  sources:
    users:
      type: http
      url: https://jsonplaceholder.typicode.com/users
      timeout: 10s
    # Source-level `pipe:` — the same sugar applied directly on a
    # source. Renames the leaf to `_biz_only_raw` (hidden) and
    # creates a pipeline `biz_only` with `from: _biz_only_raw,
    # pipe: [...]`.
    #
    #   task wrangl -- examples/inline_pipe_demo.yaml biz_only | jq '.[].website'
    #
    # Use this when the source you care about consuming downstream is
    # ALWAYS this transformed shape and the raw response isn't
    # interesting on its own. Not valid for `type: merge`.
    biz_only:
      type: http
      url: https://jsonplaceholder.typicode.com/users
      timeout: 10s
      pipe:
        - type: filter
          where: "hasSuffix(website, '.biz')"
    # Single-stage `pipe:` is sugar for the operator itself — no
    # intermediates are created, just slightly different YAML.
    biz_users:
      type: passthrough
      from: users
      pipe:
        - type: filter
          where: "hasSuffix(website, '.biz')"
    # N-stage chain: filter → project → sort, all in one declaration.
    # Reads top-to-bottom in execution order.
    short_summary:
      type: passthrough
      from: users
      pipe:
        - type: filter
          where: "len(username) >= 8"
        - type: project
          keep:
            username: username
            name: name
            domain: "lower(website)"
        - type: sort
          by: username
    # Mix derive in the middle of a chain. Each stage sees the previous
    # stage's output shape — `username_len` exists from stage 1 onward.
    labelled_long:
      type: passthrough
      from: users
      pipe:
        - type: derive
          compute:
            username_len: "len(username)"
            domain: "lower(website)"
        - type: filter
          where: "username_len >= 10"
        - type: sort
          by: username_len
          order: desc
tui:
  components:
    table:
      type: table
      title: Users
      columns:
        - {title: Username, width: 16, sortable: true, value: username}
        - {title: Name, width: 26, sortable: true, value: name}
        - {title: Domain, width: 24, value: domain}
      source: short_summary
  screen:
    layout:
      component: table

Screens, prompts and actions

multi

Multi-screen drill-down with breadcrumbs and ${selection}.

task example NAME=multi
multi.yaml
# multi.yaml — three-level drilldown demonstrating multi-screen navigation.
#
#   regions  ──enter──▶  cities (in selected region)  ──enter──▶  detail
#                                                                   ▲
#                                                          ${selection}
#                                                          substituted at push time
#
# Breadcrumb fills as you push:    My-App › Regions › Europe › London
# esc pops back; q at the root quits.
#
# A push action wires a keystroke on <from> to open <push>.
# The source component's selection is captured and substituted into the
# target screen's config as ${selection} (and ${selection.COLNAME} or
# ${selection.N} for table sources).
app:
  title: World Atlas
  theme: nord

tui:
  components:
    regions:
      type: list
      title: Regions
      filterable: true
      items:
        - Europe
        - Asia
        - Americas
        - Africa

    # Same component re-used across "cities" screens (one per region) —
    # each push substitutes the parent selection into Title + Items.
    region_cities:
      type: list
      title: Cities in ${selection}
      filterable: true
      items:
        - London
        - Paris
        - Berlin
        - Madrid
        - Reykjavík
        - Istanbul

    city_detail:
      type: inspector
      title: ${selection}
      initial_depth: 2
      fields:
        - {label: name,        value: "${selection}"}
        - label: location
          children:
            - {label: country,   value: TBD}
            - {label: continent, value: TBD}
        - label: stats
          children:
            - {label: founded, value: TBD}
            - {label: notes,   value: "Detail page for ${selection} — populate from a data source later."}
  screens:
    regions:
      title: Regions
      layout: {component: regions}
      actions:
        - {key: enter, name: open_cities, push: cities, from: regions}

    cities:
      title: ${selection}
      layout: {component: region_cities}
      actions:
        - {key: enter, name: open_detail, push: detail, from: region_cities}

    detail:
      title: ${selection}
      layout: {component: city_detail}
  initial: regions

push_actions

Push actions: enter drills into repos, a keyless action opens starred repos from the menu.

task example NAME=push_actions
push_actions.yaml
# push_actions.yaml — screen pushes as actions.
#
# A push is an action: `push:` names a screen in `tui.screens:`, and the
# binding's `bind:` fills that screen's declared `parameters:`. There is
# no separate `on_key:` block any more — one registry, one place a key
# can come from.
#
# How a push is reached:
#
#   key: enter   fires directly (double-click is its mouse spelling).
#                The classic drilldown.
#   key: <other> a MENU shortcut — dispatches while `a` is open, does
#                nothing pressed on the screen itself.
#   no key       menu-only. A parallel drilldown no longer has to earn
#                a letter to exist.
#
# This demo uses the same GitHub users → repo drilldown as the vanilla
# http example but exposes TWO push targets on the same list:
#
#   users  enter → repo table (all this user's public repos)
#          (menu) → starred (what THIS user has starred)
#   repos  enter → repo detail (inspector over one repo)
#
# Both destinations bind `${selection}` = the focused login into their
# parameterized source, so the same list feeds two different views.
#
# The third level shows how far a binding can reach: each push binds
# only from the component the CURRENT screen displays. By the time you
# are on the repos table the login is gone, so repo_detail is keyed by
# the `owner/repo` slug that the table itself carries in a column. If a
# child needs a value, the parent has to show it.
#
# A double click on a row is the mouse spelling of enter, so both
# drilldowns work from the mouse too.
app:
  title: GitHub (key pushes)
  theme: nord

data:
  sources:
    users:
      type: http
      url: https://api.github.com/users?per_page=30
      refresh: 10m
      timeout: 15s

    repos_by_user:
      type: http
      parameters:
        login: {type: string, required: true, description: GitHub login}
      url:     https://api.github.com/users/${params.login}/repos?per_page=30&sort=updated
      refresh: 10m
      timeout: 15s

    starred_by_user:
      type: http
      parameters:
        login: {type: string, required: true, description: GitHub login}
      url:     https://api.github.com/users/${params.login}/starred?per_page=30
      refresh: 10m
      timeout: 15s

    # Third level. Parameterized by the full `owner/repo` slug rather
    # than a bare name because the repo endpoint needs both halves, and
    # the repos table already has the slug in a column — a push can only
    # bind what its source component displays.
    repo_detail:
      type: http
      parameters:
        full_name: {type: string, required: true, description: "owner/repo slug"}
      url:     https://api.github.com/repos/${params.full_name}
      refresh: 10m
      timeout: 15s

tui:
  components:
    users_list:
      type: list
      title: Users
      filterable: true
      source: users
      item: login

    repos_table:
      type: table
      title: ${selection} — repos
      filterable: true
      source: repos_by_user
      initial_sort: {column: Stars, desc: true}
      columns:
        # full_name, not name: this column is what `enter` binds into
        # repo_detail's `full_name` param, and the API wants owner/repo.
        - {title: Repo,     width: 32, sortable: true,                       value: full_name}
        - {title: Language, width: 14, sortable: true,                       value: language}
        - {title: Stars,    width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}
        - {title: Updated,  width: 22, sortable: true,                       value: updated_at}

    starred_table:
      type: table
      title: ${selection} — starred
      filterable: true
      source: starred_by_user
      initial_sort: {column: Stars, desc: true}
      columns:
        - {title: Repo,     width: 40, sortable: true,                       value: full_name}
        - {title: Language, width: 14, sortable: true,                       value: language}
        - {title: Stars,    width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}

    repo_inspector:
      type: inspector
      title: ${selection.Repo}
      filterable: true
      initial_depth: 2
      source: repo_detail
      auto: true
      #      fields:
      #        - {label: Full name,      path: full_name}
      #        - {label: Description,    path: description}
      #        - {label: Homepage,       path: homepage}
      #        - {label: Language,       path: language}
      #        - {label: License,        path: "license.spdx_id"}
      #        - {label: Default branch, path: default_branch}
      #        - label: Stats
      #          children:
      #            - {label: Stars,       path: stargazers_count}
      #            - {label: Forks,       path: forks_count}
      #            - {label: Open issues, path: open_issues_count}
      #        - label: Timestamps
      #          children:
      #            - {label: Created, path: created_at}
      #            - {label: Updated, path: updated_at}
      #            - {label: Pushed,  path: pushed_at}

  screens:
    users:
      title: Users
      layout: {component: users_list}
      actions:
        # enter → user's own repos (classic drilldown; spell key explicitly)
        - key:    enter
          name:   open_repos
          push:   repos
          from:   users_list
          bind:
            login: ${selection}
        # What the user has starred — NO key at all. Press `a` and pick
        # it. A parallel drilldown no longer has to earn a letter to
        # exist, which is the whole point of the menu. `label:` is what
        # the menu shows.
        - name:   open_starred
          push:   starred
          from:   users_list
          label:  starred
          bind:
            login: ${selection}

    repos:
      title: ${selection} — repos
      layout: {component: repos_table}
      actions:
        # Third level: enter on a repo row opens its detail. Each screen
        # binds only from the row IT shows — the login picked two screens
        # back is long out of scope here, which is why the slug travels
        # in the Repo column rather than being reassembled from parts.
        - key:    enter
          name:   open_repo_detail
          push:   repo_detail
          from:   repos_table
          bind:
            full_name: ${selection.Repo}

    starred:
      title: ${selection} — starred
      layout: {component: starred_table}

    repo_detail:
      title: ${selection.Repo}
      layout: {component: repo_inspector}

  initial: users

on_cursor

on_cursor:: a repos table drives a detail inspector below it.

task example NAME=on_cursor
on_cursor.yaml
# on_cursor.yaml — reactive detail-on-hover pattern.
#
# The on-hover stack from the tui-builder integration batch:
#
#   #41 RowFocusedMsg    (tuilib) — tables emit when the cursor lands
#                        on a different row
#   #42 hidden columns   (tuilib) — cells stay in the row payload +
#                        Selected() output but don't render on screen
#   ${cursor.*} tokens   (this)   — templates resolved against the
#                        driving component's live cursor
#   on_cursor: block     (this)   — declares which table drives which
#                        dependent source, and how row cells feed the
#                        target's parameters
#   ParamCache           (this)   — reused from feature G. Cursor
#                        sweeps that revisit rows within the TTL don't
#                        re-hit the upstream.
#
# The demo:
#
#   Top pane:    GitHub repo table (name / language / star count).
#                Owner is a HIDDEN column — it stays in the row payload
#                so ${cursor.Owner} resolves, but it doesn't take up
#                screen real estate. Fine-grained detail bindings
#                without visual noise.
#   Bottom pane: inspector, source-bound to a parameterized repo-
#                detail endpoint. `auto: true` derives the field tree
#                from whatever GitHub returns (feature B).
#                `on_cursor:` binds the two — scroll the top pane and
#                the bottom pane refreshes with the currently-focused
#                repo, no Enter required.
#
# Try it:
#   task tui-builder -- examples/on_cursor.yaml
#
# Then scroll through the top table with j/k. Each new row swaps the
# bottom pane's content. Revisit a row you've already viewed and the
# ParamCache serves it instantly (no network).
app:
  title: Repos (on-hover detail)
  theme: nord

data:
  sources:
    repos:
      type: http
      # A well-known org with a manageable page size — no auth
      # needed for public repos at this rate.
      url:     https://api.github.com/orgs/charmbracelet/repos?per_page=30&sort=updated
      refresh: 30m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json

    repo_detail:
      type: http
      parameters:
        owner: {type: string, required: true, description: repo owner (login)}
        name:  {type: string, required: true, description: repo name}
      url:     https://api.github.com/repos/${params.owner}/${params.name}
      timeout: 15s
      headers:
        Accept: application/vnd.github+json
      # Bounded LRU with per-entry TTL, single-flight — the primitive
      # shipped with feature G. Every cursor move consults this cache
      # before hitting the upstream. Revisiting a row you already
      # focused within 5m returns the memoised value instantly.
      cache:
        ttl:  5m
        size: 200

tui:
  components:
    repo_table:
      type: table
      title: Repos
      filterable: true
      source: repos
      initial_sort: {column: Stars, desc: true}
      columns:
        # Hidden column — the row still carries `owner.login` so
        # ${cursor.Owner} resolves, but the column is invisible.
        # Feature #42.
        - {title: Owner,    hidden: true,           value: "owner.login"}
        - {title: Name,     width: 30, sortable: true, value: name}
        - {title: Language, width: 14, sortable: true, value: language}
        - {title: Stars,    width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}
        - {title: Updated,  width: 22, sortable: true, value: updated_at}

    repo_inspector:
      type: inspector
      title: ${cursor.Name}
      filterable: true
      initial_depth: 1
      source: repo_detail
      auto: true       # feature B — derive fields from response shape
      # The reactive binding. Whenever repo_table's cursor lands on a
      # different row, the inspector's `repo_detail` source rebinds
      # its params from the driver's row cells (via ${cursor.*}) and
      # refetches through the ParamCache.
      on_cursor:
        source: repo_table
        bind:
          owner: ${cursor.Owner}
          name:  ${cursor.Name}

  screen:
    title: Repos
    layout:
      vstack:
        - {flex: 1, component: repo_table}
        - {flex: 2, component: repo_inspector}

on_cursor_fs

on_cursor: from a filesystem tree; ${cursor.path} feeds stat.

task example NAME=on_cursor_fs
on_cursor_fs.yaml
# on_cursor_fs.yaml — filesystem tree with structured `stat` details on hover.
#
# Demonstrates on_cursor + tree-driver pattern. Cursor on any tree
# node (directory or file) → the right pane refetches `stat` for that
# path and renders each field label → value via an auto-inspector.
#
# Pieces on display:
#
#   #38 tree SetRoot            → applyTree rebuilds in place; expand
#                                 state + cursor survive polling.
#   nested tree walking          → `children: contents` recurses through
#                                 whatever depth the source returns.
#   #45 tree.SelectedChangedMsg → drives the reactive refetch.
#   ${cursor.path}               → joins the driver's cell chain into
#                                 "cmd/wrangl/main.go", fed straight to
#                                 `stat` as its argument.
#   feature B (inspector auto)   → the exec emits JSON; the inspector
#                                 renders each key label → value with
#                                 no field declaration needed.
#   feature G ParamCache         → cursor sweeps that revisit paths
#                                 within the TTL don't re-exec stat.
#
# Requires the `tree` CLI (JSON output flag). If missing:
#   brew install tree     # macOS
#   apt install tree      # Debian/Ubuntu
#
# Runs on macOS and Linux: the stat command below picks its format flag
# from `uname`, because BSD and GNU stat disagree about what `-f` means.
#
# Try it:
#   task tui-builder -- examples/on_cursor_fs.yaml
#
# Scroll with j/k, expand with space. The right pane updates continuously.
app:
  title: Filesystem browser
  theme: nord

data:
  sources:
    walk:
      type:    exec
      command: [tree, -J, -L, "4", "cmd"]
      root:    "[0]"
      refresh: 30s
      timeout: 10s

    stat_detail:
      type: exec
      parameters:
        path: {type: string, default: ".", description: filesystem path from the tree cursor}
      # stat with a printf-style format that assembles a valid JSON
      # object on stdout. format: json parses that into a map so the
      # bound inspector renders each key as a labeled row.
      #
      # The sh wrapper is here for one reason: BSD and GNU stat share no
      # format flag. `-f` is "use this format" on macOS and
      # "--file-system" on Linux, where the format string is then read as
      # a filename — the command fails with output that isn't JSON, and
      # the inspector silently renders empty. One argv cannot serve both,
      # so branch on uname.
      #
      # ${params.path} is substituted into the argv AT PIPELINE.BUILD
      # TIME (see pipeline.Build's leaf branch), so it arrives as $0 —
      # still no env plumbing, and no quoting of the path into the script.
      command:
        - sh
        - -c
        - |
          if [ "$(uname)" = Darwin ]; then
            stat -f '{"path":"%N","size":%z,"mode":"%Sp","owner":"%Su","group":"%Sg","modified":"%Sm","kind":"%HT"}' "$0"
          else
            stat --printf='{"path":"%n","size":%s,"mode":"%A","owner":"%U","group":"%G","modified":"%y","kind":"%F"}' "$0"
          fi
        - ${params.path}
      format: json
      cache:
        ttl:  60s
        size: 500

tui:
  components:
    files:
      type:          tree
      title:         cmd
      searchable:    true
      initial_depth: 3
      source:        walk
      # root_label matches the response's own top-level name ("cmd"
      # from `tree -J cmd`) so tuilib's expand-state seed carries
      # across the first SetRoot swap.
      root_label:    cmd
      label:    name
      children: contents

    stat_pane:
      type:       inspector
      title:      ${cursor.path}
      filterable: true
      auto:       true       # feature B — derive fields from the JSON
      source:     stat_detail
      # Refetch on every cursor move. ${cursor.path} joins the driver
      # tree's ancestor labels with "/" into a real filesystem path.
      on_cursor:
        source: files
        bind:
          path: ${cursor.path}

  screen:
    title: Filesystem
    layout:
      hstack:
        - {flex: 1, component: files}
        - {flex: 1, component: stat_pane}

prompts_boot

app.prompts: a boot form whose answers feed the URL, title and a column.

task example NAME=prompts_boot
prompts_boot.yaml
# prompts_boot.yaml — boot-time parameters via app.prompts.
#
# At app startup, BEFORE the main screen renders, tui-builder opens a
# form to collect any app.prompts values. Each value is set as an env
# var (via os.Setenv) keyed by the prompt's `key`, so the existing
# ${env.<KEY>} substitution covers BOTH OS env vars and boot-time
# inputs — one namespace, single substitution path.
#
# This example: prompt for a GitHub username + sort field, then feed
# both into a live HTTP source URL.
#
# Pre-populating: if the env var named by a prompt's Key is already
# set, the text input pre-fills with that value. So:
#
#   USER=charmbracelet tui-builder examples/prompts_boot.yaml
#
# skips typing — just press enter on the pre-filled form.
#
# Cancelling (esc) aborts the program.
app:
  title: GitHub by user
  theme: nord
  # Boot prompts share the Parameter vocabulary with action inputs, so
  # the widget follows the data type: options: means select, type: bool
  # means toggle, everything else is text. `default:` is the starting
  # value for all three (name the option by value, not by index).
  prompts:
    - key:   USER
      label: GitHub username
      placeholder: torvalds
      default: torvalds
    - key:   SORT
      label: Sort by
      options: [updated, created, pushed, full_name]
      default: updated
    - key:   ARCHIVED
      label: Include archived repos?
      type:  bool
      default: "false"
    # mask: true renders what you type as bullets. The value is a normal
    # string everywhere else — ${env.GITHUB_TOKEN} below substitutes the
    # real text — so this is about not showing a token on a screen
    # someone may be sharing, not about storing one securely.
    #
    # It is a field rather than a type because `type:` is the DATA type
    # here (string / int / bool / duration) and masking is a rendering
    # choice on a string.
    #
    # Leave it blank to stay on unauthenticated rate limits.
    - key:   GITHUB_TOKEN
      label: GitHub token (optional)
      mask:  true

data:
  sources:
    repos:
      type: http
      # The first three boot prompts feed into the URL. The fourth is the
      # masked token — uncomment the Authorization header below to use it
      # for higher rate limits. Left commented because an empty token
      # sends `Bearer ` and GitHub 401s that, which would make the
      # example fail for anyone who skips the prompt.
      url: https://api.github.com/users/${env.USER}/repos?sort=${env.SORT}&per_page=50
      refresh: 5m
      timeout: 15s
      headers:
        Accept: application/vnd.github+json
        # Authorization: "Bearer ${env.GITHUB_TOKEN}"

tui:
  components:
    repos_table:
      type: table
      title: ${env.USER}'s repos — sort=${env.SORT}, archived=${env.ARCHIVED}
      filterable: true
      source: repos
      initial_sort: {column: Stars, desc: true}
      columns:
        - {title: Repo,        width: 32, sortable: true, value: full_name}
        - {title: Language,    width: 14, sortable: true, value: language}
        - {title: Stars,       width: 8,  sortable: true, sort: number, align: right, value: stargazers_count}
        - {title: Forks,       width: 8,  sortable: true, sort: number, align: right, value: forks_count}
        - {title: Updated,     width: 22, sortable: true, value: updated_at}
        - {title: Archived,    width: 9,  align: center,                value: archived}
        - {title: Description, width: 60,                               value: description}
  screen:
    title: ${env.USER}
    layout:
      component: repos_table

action_prompts

The action menu, and inputs collected in a generated form. Runs anywhere.

task example NAME=action_prompts
action_prompts.yaml
# action_prompts.yaml — the action menu, and actions that ask first.
#
# Press `a` for the menu. That is where verbs live now: it lists every
# action this screen has, with its shortcut in the right column and a
# reason next to anything unavailable. `enter` is the only key that
# still fires an action directly, because it is activation rather than a
# shortcut — which is why a verb needs no key at all unless you want
# one. See `taste` below: no key, menu only.
#
# There is no `prompts:` schema. An action declares typed `inputs:`, and
# anything the binding doesn't fill via `bind:` is collected in a form
# generated from those same declarations. One vocabulary, one namespace
# (${inputs.*}), and the form can't drift out of step with the argv
# because it is derived from it.
#
# The widget follows the data type:
#   (default)          single-line text input
#   options: [...]     select, whatever the type
#   type: bool         yes/no toggle
#   mask: true         bullets instead of characters
#
# Flow when an action fires:
#   1. bind: templates resolve against the focused row
#   2. any input still unset (and without a default) opens a form
#   3. (if confirm:) a modal, which can preview ${inputs.*}
#   4. dispatch — output streams into the output console (press `o`)
#
# Every action here runs `echo`, so nothing external is needed.
app:
  title: Action inputs demo
  theme: nord

# The actions themselves — reusable, inspectable without a TTY:
#   wrangl --list-actions examples/action_prompts.yaml
#   wrangl --describe-action examples/action_prompts.yaml order --param fruit=Apple
actions:
  request:
    description: Ask for a quantity, then echo it
    inputs:
      # Bound from the selected row, so it never reaches the form.
      fruit:  {type: string, required: true}
      # Not bound by the binding below → this is what gets prompted for.
      amount: {label: "How many?", placeholder: "1", order: 1}
    run: [echo, "${inputs.amount} ${inputs.fruit}(s) requested"]

  order:
    description: Multi-field order form
    inputs:
      fruit:    {type: string, required: true}
      amount:   {label: Quantity, placeholder: "1", order: 1}
      # Options make it a select regardless of type; `default:` names the
      # starting choice by value rather than by index.
      priority: {label: Priority, options: [low, normal, high], default: normal, order: 2}
      # A bool renders the yes/no toggle.
      gift:     {label: "Gift wrap?", type: bool, default: "false", order: 3}
    run:
      - sh
      - -c
      - 'echo "ordered: ${inputs.amount} × ${inputs.fruit}, priority=${inputs.priority}, gift=${inputs.gift}"'
    message: "ordered ${inputs.amount} × ${inputs.fruit}"

  taste:
    description: A verb with no key, to prove one isn't needed
    inputs:
      fruit: {type: string, required: true}
    run: [echo, "tasting ${inputs.fruit}"]
    message: "tasted ${inputs.fruit}"

tui:
  components:
    fruits:
      type: list
      title: Pick a fruit, then press a key for an action
      filterable: true
      items:
        - Apple
        - Banana
        - Cherry
        - Date
  screen:
    title: Inputs
    layout:
      component: fruits
    actions:
      # `enter` — the one key that still fires directly. Everything else
      # is reached through the menu.
      - key: enter
        action: order
        label: order
        from: fruits
        # The confirm message previews values the form just collected.
        confirm: "Order ${inputs.amount} ${selection}(s) as ${inputs.priority} priority?"
        bind: {fruit: "${selection}"}

      # `n` is a MENU shortcut, not a direct key: it dispatches while the
      # menu is open. Pressing `n` on the list does nothing.
      - key: n
        action: request
        label: request
        from: fruits
        bind: {fruit: "${selection}"}

      # No key at all — reachable only from the menu. This is the payoff:
      # the alphabet stops being the ceiling on how many verbs a screen
      # can have, so a verb no longer has to earn a letter to exist.
      - action: taste
        label: taste
        from: fruits
        bind: {fruit: "${selection}"}

action_http_argocd

type: http actions against Argo CD: bearer auth, success: and error_message: ${body.message}.

task example NAME=action_http_argocd
action_http_argocd.yaml
# action_http_argocd.yaml — `type: http` actions against a real API.
#
# Most actions should be subprocesses: shelling out to a CLI inherits its
# auth, context selection, and RBAC for free, which is why the kube
# example calls kubectl rather than the API it reads from. HTTP actions
# earn their place when there is no CLI in the loop — an internal service,
# or a workflow already wired to a token and an endpoint. Argo CD is the
# case this was built for.
#
# Setup (nothing here needs a cluster to *load* — `wrangl --list-actions`
# and `--describe-action` work offline):
#
#   export ARGOCD_URL=https://argocd.example.com
#   export ARGOCD_TOKEN=...          # argocd account generate-token
#
# Inspect before firing anything:
#   wrangl --list-actions examples/action_http_argocd.yaml
#   wrangl --describe-action examples/action_http_argocd.yaml sync_app --param app=my-app
#
# Note what this example deliberately does NOT do: refresh the table
# after a sync. A view owns its own refresh cycle — `apps` polls every
# 10s below — and it converges on its own. Coupling the action to a
# repaint would make every action responsible for knowing which panes it
# invalidated, and an argocd sync is asynchronous anyway: the POST
# returns immediately and the work happens afterwards, so the honest
# picture comes from polling regardless.
app:
  title: Argo CD
  theme: nord

data:
  sources:
    apps:
      type: http
      url: ${env.ARGOCD_URL}/api/v1/applications
      root: items
      headers:
        Authorization: "Bearer ${env.ARGOCD_TOKEN}"
      # The convergence story. After a sync, this is what walks the row
      # from OutOfSync → Progressing → Synced without anyone asking it to.
      refresh: 10s
      timeout: 15s

actions:
  sync_app:
    description: Trigger a sync for one application
    type: http
    method: POST
    url: ${env.ARGOCD_URL}/api/v1/applications/${inputs.app}/sync
    headers:
      Authorization: "Bearer ${env.ARGOCD_TOKEN}"
    body: '{"prune": ${inputs.prune}, "dryRun": false}'
    inputs:
      app:   {type: string, required: true}
      # Unbound below, so this opens a toggle when the action fires.
      prune: {type: bool, label: "Prune resources?", default: "false"}
    # A sync POST against an app that's already syncing returns 409.
    # That's not a failure worth painting the badge red — the thing you
    # asked for is already happening.
    success: code < 400 or code == 409
    message: "sync requested for ${inputs.app}"
    # Without this the summary would be "POST …/sync failed → 403" and
    # the actual reason would sit unread in a body too long for a footer.
    error_message: ${body.message}

  refresh_app:
    description: Force a hard refresh of one application's state
    type: http
    method: GET
    url: ${env.ARGOCD_URL}/api/v1/applications/${inputs.app}?refresh=hard
    headers:
      Authorization: "Bearer ${env.ARGOCD_TOKEN}"
    inputs:
      app: {type: string, required: true}
    message: "refreshed ${inputs.app}"
    error_message: ${body.message}

tui:
  components:
    apps_table:
      type: table
      title: Applications
      filterable: true
      source: apps
      columns:
        - {title: Name,      width: 30, sortable: true, value: metadata.name}
        - {title: Project,   width: 14, sortable: true, value: spec.project}
        - {title: Sync,      width: 12, sortable: true, value: status.sync.status}
        - {title: Health,    width: 12, sortable: true, value: status.health.status}
        - {title: Revision,  width: 12,                 value: status.sync.revision}
      color_rules:
        - {when: Synced,      color: green}
        - {when: OutOfSync,   color: yellow}
        - {when: Healthy,     color: green}
        - {when: Degraded,    color: red}
        - {when: Progressing, color: cyan}

  screen:
    title: Argo CD
    layout:
      component: apps_table
    actions:
      - key: s
        action: sync_app
        label: sync
        from: apps_table
        confirm: "Sync ${selection.Name}?"
        bind: {app: "${selection.Name}"}

      - key: R
        action: refresh_app
        label: hard refresh
        from: apps_table
        bind: {app: "${selection.Name}"}

Row activity

activity

Pods spin while Pending or Terminating, read from the data every 2s poll; enter claims a row for a pretend restart. Start with FAIL=1 for the "reads failing" title.

task example NAME=activity
activity.yaml
# activity.yaml — rows that spin while the data says they're busy.
#
# `activity:` reads one field per row and decides whether that row is
# being worked on. Here a script stands in for `kubectl get pods`: every
# few seconds each pod moves through Pending → Running → Terminating, and
# the table, polling every 2s, spins the rows that are Pending or
# Terminating with their phase beside the spinner. The server is the
# truth: a row starts and stops spinning when a read says so.
#
# Press enter on a pod to restart it: the action claims the row, so it
# spins "Restarting" at once rather than waiting for the next poll, and
# stops when the (pretend) restart returns. Mark several with x and run
# it from the menu (a) to restart them all, one claim per row.
#
# Make the script fail (set FAIL=1 in your shell before starting) to see
# the outage marker: the title says "· reads failing", the rows keep what
# the last good read said, and the error is reported once.
app:
  title: Pods
  theme: nord
  env:
    - {name: FAIL, default: "0", description: "1 makes every read fail"}
data:
  sources:
    pods:
      type: exec
      refresh: 2s
      command:
        - sh
        - -c
        - |
          [ "$FAIL" = 1 ] && { echo "cluster unreachable" >&2; exit 1; }
          now=$(date +%s)
          printf '['
          for i in 0 1 2 3 4 5; do
            case $(( (now / 3 + i) % 6 )) in
              0) phase=Pending ;; 1|2|3) phase=Running ;; 4) phase=Terminating ;; *) phase=Running ;;
            esac
            [ $i -gt 0 ] && printf ','
            printf '{"metadata":{"uid":"uid-%d","name":"web-%d"},"status":{"phase":"%s"},"node":"node-%d"}' $i $i $phase $((i % 3))
          done
          printf ']'
      env:
        FAIL: ${env.FAIL}
tui:
  components:
    pods:
      type: table
      title: Pods
      source: pods
      mark_key: metadata.uid
      markable: true
      activity:
        status: status.phase
        busy: [Pending, Terminating]
      columns:
        - {title: Name, value: metadata.name, width: 10}
        - title: Phase
          value: status.phase
          width: 15
          color_rules:
            - {when: Running, color: green}
        - {title: Node, value: node, flex: 1}
  screen:
    title: Pods
    layout:
      component: pods
    actions:
      - key: enter
        action: restart
        from: pods
        bind: {name: "${selection.Name}"}
actions:
  restart:
    description: Pretend to restart a pod (takes 3s)
    multi: true
    run: [sh, -c, "sleep 3; echo restarted ${inputs.name}"]
    inputs:
      name: {required: true}
    claim: Restarting

Kubernetes

kube

Namespaces → pods → pod detail and a tailing log. Needs kubectl proxy --port=8001.

task example NAME=kube
kube.yaml
# kube.yaml — namespaces → pods → {pod inspector + logs} via kubectl proxy.
#
# Prereq:
#   kubectl proxy --port=8001    # run in another terminal; no auth header
#                                # needed because kubectl proxy uses your
#                                # kubeconfig under the hood
#
# Drilldown:
#   ┌ Namespaces ─┐  enter   ┌ Pods (in ${ns}) ┐  enter   ┌ Pod inspector ┐
#   │ default     │ ──────▶  │ pod-a   Running │ ──────▶  │  metadata     │
#   │ kube-system │          │ pod-b   Running │          │  spec         │
#   │ …           │          │ pod-c   Pending │          │  status       │
#   └─────────────┘          └─────────────────┘          ├ Logs (tail) ──┤
#                                                         │ 12:00 INFO …  │
#                                                         │ 12:00 WARN …  │
#                                                         └───────────────┘
#
# The pod-detail screen polls pod state every 5s and tails logs every 2s.
# Both refreshes update in place — filter/cursor/scroll survive.
#
# K8s response shape:
#   List endpoints wrap their array in `{items: [...]}`. We set
#   `root: items` on each list source so the bindings see the array.
#   Detail endpoints return a single object — no root needed.
app:
  title: Kubernetes
  theme: nord

data:
  sources:
    # namespaces is self-contained — no caller input needed. Lifecycle
    # stays polled (refresh: 30s); the TUI and wrangl both run it the
    # same way.
    namespaces:
      type: http
      url: http://localhost:8001/api/v1/namespaces
      root: items
      refresh: 30s
      timeout: 10s

    # pods takes one required parameter (namespace). At push time, the
    # parent screen's `bind:` block (see screens.namespaces.actions)
    # resolves ${selection} → the picked namespace name and feeds it
    # into ${params.namespace}. From wrangl: `--param namespace=default`.
    pods:
      type: http
      parameters:
        namespace:
          type: string
          required: true
          description: Kubernetes namespace to list pods in
      url: http://localhost:8001/api/v1/namespaces/${params.namespace}/pods
      root: items
      refresh: 5s
      timeout: 10s

    # pod_detail and pod_logs are both two-param (namespace + pod name).
    # In the TUI they're bound at the pods→detail push site; from wrangl
    # you pass both: `--param namespace=default --param name=nginx-xyz`.
    pod_detail:
      type: http
      parameters:
        namespace: { type: string, required: true, description: pod namespace }
        name:      { type: string, required: true, description: pod name }
      url: http://localhost:8001/api/v1/namespaces/${params.namespace}/pods/${params.name}
      refresh: 5s
      timeout: 10s

    pod_logs:
      type: http
      parameters:
        namespace: { type: string, required: true, description: pod namespace }
        name:      { type: string, required: true, description: pod name }
      # follow: true holds the request open and reads the response body
      # line-by-line — each log line becomes a streaming Event. This is
      # the kube API's `?follow=true` mode: server sends new lines as
      # they're written, no polling required.
      #
      # From wrangl, pair with --raw to see plain `kubectl logs`-style
      # output instead of JSON-quoted lines:
      #
      #   task wrangl -- examples/kube.yaml pod_logs \
      #     --param namespace=demo --param name=hello-... --raw
      #
      # tailLines=200 seeds the stream with recent history; new lines
      # follow as the pod emits them.
      url: http://localhost:8001/api/v1/namespaces/${params.namespace}/pods/${params.name}/log?follow=true&tailLines=200
      follow: true
      format: text

tui:
  components:
    namespaces_list:
      type: list
      title: Namespaces
      filterable: true
      source: namespaces
      item: "metadata.name"
      colors:
        border_active: theme:accent
        selected:      theme:accent
      # color_rules on a list apply per item. Regex (~) is the natural
      # match for "names that start with kube-"; later exact matches
      # promote our demo namespaces in the palette accent so they stand
      # out against the system noise.
      color_rules:
        - {when: "~^kube-", color: gray}
        - {when: default,   color: theme:accent}
        - {when: demo,      color: green}

    pods_table:
      type: table
      title: ${selection}
      filterable: true
      source: pods
      initial_sort: {column: Name, desc: false}
      # Multi-select. `x` marks a row, `X` extends from the last mark,
      # `A` marks all, `D` drops them. Marks are held by KEY, so the 5s
      # poll below can reorder or replace rows without the selection
      # sliding onto a neighbour.
      #
      # `mark_key:` is required and is read from the source item, not a
      # column — `metadata.uid` is the only field here that is unique and
      # stable. Name repeats across namespaces; STATUS and the age
      # change under you. Both would fail silently.
      markable: true
      mark_key: metadata.uid
      columns:
        - {title: Name,      width: 36, sortable: true, value: "metadata.name"}
        - {title: Namespace, width: 18, sortable: true, value: "metadata.namespace"}
        # Data-driven cell colors: `color_rules` evaluate in order, first
        # match wraps the cell with ansi.CellColor (selected-row bg
        # passes through). `when:` accepts exact strings, "~regex",
        # ">N" / ">=N" / "<N" / "<=N" / "==N" / "!=N" comparisons, or
        # empty for a terminal default. `color:` takes the same forms as
        # the per-component colors: block (named / 0-255 / hex / theme:*).
        # `value:` accepts a single dot-path OR a fallback chain (list of
        # paths, first non-empty wins). This is how kubectl computes its
        # STATUS column: container waiting reason (CrashLoopBackOff,
        # ImagePullBackOff, …) wins over terminated reason (Error,
        # Completed) which wins over pod phase (Running, Pending, …).
        # status.phase alone shows "Running" for crash-looping pods.
        - title: Status
          width: 18
          sortable: true
          value:
            - "status.containerStatuses.0.state.waiting.reason"
            - "status.containerStatuses.0.state.terminated.reason"
            - "status.phase"
          color_rules:
            - {when: Running,           color: green}
            - {when: Pending,           color: yellow}
            - {when: Succeeded,         color: theme:accent}
            - {when: Completed,         color: theme:accent}
            - {when: Failed,            color: red}
            - {when: Error,             color: red}
            - {when: CrashLoopBackOff,  color: red}
            - {when: ImagePullBackOff,  color: red}
            - {when: ErrImagePull,      color: red}
            - {when: ContainerCreating, color: yellow}
            - {when: Unknown,           color: gray}
        - title: Ready
          width: 6
          align: right
          value: "status.containerStatuses.0.ready"
          color_rules:
            - {when: "true",  color: green}
            - {when: "false", color: red}
        - title: Restarts
          width: 8
          sortable: true
          sort: number
          align: right
          value: "status.containerStatuses.0.restartCount"
          color_rules:
            - {when: "0",   color: gray}
            - {when: ">5",  color: red}
            - {when: ">0",  color: yellow}
        - {title: Node,    width: 24, value: "spec.nodeName"}
        - {title: Pod IP,  width: 16, value: "status.podIP"}
        - {title: Started, width: 22, sortable: true, value: "status.startTime"}

    pod_inspector:
      type: inspector
      title: ${selection.Name}
      filterable: true
      initial_depth: 0
      source: pod_detail
      colors:
        border_active:   theme:accent
        label:           theme:muted
        value:           theme:bar-fg
        current_line_bg: theme:subtle
      fields:
        - {label: name,       path: "metadata.name"}
        - {label: namespace,  path: "metadata.namespace"}
        - {label: nodeName,   path: "spec.nodeName"}
        # ColorRules also apply per inspector field — value text wrapped
        # with ansi.CellColor on match (same syntax as table color_rules).
        - label: phase
          path: "status.phase"
          color_rules:
            - {when: Running,   color: green}
            - {when: Pending,   color: yellow}
            - {when: Succeeded, color: theme:accent}
            - {when: Failed,    color: red}
        - label: podIP
          path: "status.podIP"
          # Empty-value detection: if the IP wasn't assigned yet, highlight
          # in yellow so a starting-up pod stands out from a healthy one.
          color_rules:
            - {when: "",       color: yellow}
            - {when: "~^",     color: theme:bar-fg}
        - {label: hostIP,     path: "status.hostIP"}
        - label: metadata
          children:
            - {label: uid,               path: "metadata.uid"}
            - {label: creationTimestamp, path: "metadata.creationTimestamp"}
            - {label: resourceVersion,   path: "metadata.resourceVersion"}
        - label: spec
          children:
            - {label: serviceAccountName, path: "spec.serviceAccountName"}
            - label: restartPolicy
              path: "spec.restartPolicy"
              color_rules:
                - {when: Always, color: green}
                - {when: Never,  color: gray}
                - {when: OnFailure, color: yellow}
            - label: container[0]
              children:
                - {label: name,            path: "spec.containers.0.name"}
                - {label: image,           path: "spec.containers.0.image"}
                - {label: imagePullPolicy, path: "spec.containers.0.imagePullPolicy"}
        - label: status
          children:
            - label: phase
              path: "status.phase"
              color_rules:
                - {when: Running,   color: green}
                - {when: Pending,   color: yellow}
                - {when: Succeeded, color: theme:accent}
                - {when: Failed,    color: red}
            - {label: startTime, path: "status.startTime"}
            - label: containerStatus[0]
              children:
                - label: ready
                  path: "status.containerStatuses.0.ready"
                  color_rules:
                    - {when: "true",  color: green}
                    - {when: "false", color: red}
                - label: restartCount
                  path: "status.containerStatuses.0.restartCount"
                  color_rules:
                    - {when: "0",  color: gray}
                    - {when: ">5", color: red}
                    - {when: ">0", color: yellow}
                - {label: image,   path: "status.containerStatuses.0.image"}
                - {label: imageID, path: "status.containerStatuses.0.imageID"}

    pod_logs_view:
      type: logview
      title: ${selection.Name} — logs (tail 200, refresh 2s)
      searchable: true
      source: pod_logs
      # color_rules on a logview match per line (whole-line wrap). Regex
      # via the `~` prefix is the natural choice — log levels typically
      # appear as a token somewhere in the line. First-match wins, so
      # tighter patterns go first.
      color_rules:
        - {when: "~\\b(ERROR|FATAL|PANIC)\\b", color: red}
        - {when: "~\\bWARN(ING)?\\b",          color: yellow}
        - {when: "~\\bINFO\\b",                color: theme:accent}
        - {when: "~\\b(DEBUG|TRACE)\\b",       color: gray}
        - {when: server,                       color: green}
      colors:
        border_active:   theme:accent
        match:           theme:accent
        current_line_bg: theme:subtle
  screens:
    namespaces:
      title: Namespaces
      layout: {component: namespaces_list}
      # Push site: when the user hits enter on a namespace row, push the
      # `pods` screen with the selected namespace name fed into the
      # destination's `pods` source via its declared `namespace` param.
      actions:
        - key:    enter
          name:   open_pods
          push:   pods
          from:   namespaces_list
          bind:
            namespace: ${selection}

    pods:
      title: ${selection}
      layout: {component: pods_table}
      # The pods table is bound to a parameterized source — both columns
      # (Namespace, Name) of the selected row feed into the destination
      # screen's `pod_detail` AND `pod_logs` params. Same map, both
      # destinations see the same values.
      actions:
        - key:    enter
          name:   open_detail
          push:   detail
          from:   pods_table
          bind:
            namespace: ${selection.Namespace}
            name:      ${selection.Name}
      # The rest bind to NAMED actions from the top-level `actions:`
      # block. The binding owns the TUI-side concerns — which key, which
      # pane's row feeds it (`from:`), whether to confirm — while the
      # action itself owns what actually runs.
      #
      # Only `enter` above fires from a keypress. These are reached
      # through the action menu (`a`); `key:` is their menu shortcut.
        - key: x
          action: pod_shell
          label: exec
          from: pods_table
          interactive: true
          # `confirm:` prompts a yes/no modal before dispatch. This is the
          # only blocking interrupt left in the flow — deliberately a
          # pre-action gate, since the moment to stop someone is before
          # the delete, not after.
          confirm: "Open an interactive shell in ${selection.Name} (${selection.Namespace})?"
          notice: "connecting…"
          bind:
            namespace: ${selection.Namespace}
            name:      ${selection.Name}
        - key: d
          action: pod_describe
          label: describe
          from: pods_table
          interactive: true
          bind:
            namespace: ${selection.Namespace}
            name:      ${selection.Name}
        - key: D
          action: pod_delete
          label: delete
          from: pods_table
          confirm: "Delete pod ${selection.Name} in ${selection.Namespace}? This cannot be undone."
          bind:
            namespace: ${selection.Namespace}
            name:      ${selection.Name}

    detail:
      title: ${selection.Name}
      layout:
        vstack:
          - {flex: 2, component: pod_inspector}
          - {flex: 3, component: pod_logs_view}
  initial: namespaces

# Actions — the write side, peer to `data.sources:` rather than an entry
# in it. That separation is a safety property: sources get re-fetched on
# a timer and on manual `r`, and "re-fetch" must never come to mean
# "delete the pod again."
#
# Each action declares typed `inputs:` and refers to them as
# ${inputs.<name>}. It never mentions ${selection.*} — mapping a focused
# row onto inputs is the binding's job, which is what lets the same
# action be bound from several screens.
#
# Inspect them without a TTY:
#   wrangl --list-actions examples/kube.yaml
#   wrangl --describe-action examples/kube.yaml pod_delete \
#     --param namespace=default --param name=nginx-abc
actions:
  pod_shell:
    description: Open an interactive shell in a pod
    inputs:
      namespace: {type: string, required: true}
      name:      {type: string, required: true}
    run:
      - kubectl
      - --context
      - kind-tui-builder
      - exec
      - -it
      - -n
      - ${inputs.namespace}
      - ${inputs.name}
      - --
      - sh

  pod_describe:
    description: kubectl describe, piped through a pager
    inputs:
      namespace: {type: string, required: true}
      name:      {type: string, required: true}
    run:
      - sh
      - -c
      - "kubectl --context kind-tui-builder describe -n ${inputs.namespace} pod ${inputs.name} | ${PAGER:-less} -R"

  pod_delete:
    description: Delete a pod
    # Fans out over marked rows: one kubectl per pod, each separately
    # tracked, separately cancellable, each logged under its own name.
    # Not one `kubectl delete pod a b c` — that would be a single run
    # you can only cancel wholesale, and a partial failure would be one
    # opaque exit code.
    multi: true
    inputs:
      namespace: {type: string, required: true}
      name:      {type: string, required: true}
    # No `interactive:` on the binding, so this captures instead of
    # handing over the terminal: kubectl's output streams line by line
    # into the output console (`o`) with no alt-screen flicker.
    run:
      - kubectl
      - --context
      - kind-tui-builder
      - delete
      - -n
      - ${inputs.namespace}
      - pod
      - ${inputs.name}
    message: "deleted ${inputs.name}"

kube2

Contexts from kubectl config view as the entry point. Needs kubectl.

task example NAME=kube2
kube2.yaml
app:
  title: Kubernetes
  theme: nord
data:
  sources:
    clusters:
      type: exec
      command:
        - kubectl
        - config
        - view
        - --output=json
      root: clusters
      pipe:
        - type: filter
          where: "hasPrefix(name, 'kind-')"
        - type: project
          keep:
            name: name
    namespaces:
      type: exec
      parameters:
        cluster:
          type: string
          required: true
          description: Cluster context
      command:
        - kubectl
        - --context=${params.cluster}
        - get
        - namespace
        - --output=json
      root: items
      refresh: 30s
tui:
  components:
    namespaces_list:
      type: list
      title: Namespaces
      filterable: true
      source: namespaces
      item: "metadata.name"
      colors:
        border_active: theme:accent
        selected: theme:accent
      color_rules:
        - {when: "~^kube-", color: gray}
        - {when: default, color: 'theme:accent'}
        - {when: demo, color: green}
    clusters_list:
      type: list
      title: Clusters
      source: clusters
      item: name
  screens:
    namespaces:
      title: Namespaces
      layout: {component: namespaces_list}
    clusters:
      title: Clusters
      layout: {component: clusters_list}
      #actions:
      #  - source: namespaces_list
      #    push:   pods
      #    key:    enter
      #    bind:
      #      namespace: ${selection}
  #initial: namespaces
  initial: clusters

kube_multi

Three clusters merged into one pods table with cross-cluster drill-down.

task example NAME=kube_multi
kube_multi.yaml
# kube_multi.yaml — pods across 3 local kube clusters merged into one
# searchable table, with cross-cluster drilldown into pod detail + logs.
#
# Prereq (one terminal each — see Taskfile.yml `kube:multi:*` tasks):
#   kubectl --context kind-tb-prod    proxy --port=8001
#   kubectl --context kind-tb-staging proxy --port=8002
#   kubectl --context kind-tb-dev     proxy --port=8003
#
# How the multi-cluster shape works:
#
#   - Three http sources, one per cluster, each hitting its own proxy.
#   - `pods_all` merges them. Each row is tagged with the cluster's
#     human name AND its proxy base URL via per-child tags (see
#     `children:` below). The URL tag is what makes cross-cluster
#     drilldown possible — the detail source pulls it back out of
#     the selected row to know which proxy to hit.
#   - On enter, the focused row's selection feeds bind: → params on
#     `pod_detail` + `pod_logs`. Same source definitions work for
#     every cluster because the proxy URL is just another param.
#   - `pod_logs` uses http follow (kube `?follow=true`) so the logview
#     tails live, no polling.
#
# Add a cluster: drop a new http source + add a `children:` entry with
# its `cluster` / `cluster_url` tags. Detail/logs sources need no
# changes — they're cluster-agnostic.
app:
  title: Pods across clusters
  theme: nord

data:
  sources:
    # ── per-cluster pod listings ──────────────────────────────────────
    pods_prod:
      type: http
      url: http://localhost:8001/api/v1/pods
      root: items
      refresh: 5s
      timeout: 10s

    pods_staging:
      type: http
      url: http://localhost:8002/api/v1/pods
      root: items
      refresh: 5s
      timeout: 10s

    pods_dev:
      type: http
      url: http://localhost:8003/api/v1/pods
      root: items
      refresh: 5s
      timeout: 10s

    # ── merged view with per-child tags ───────────────────────────────
    # The `children:` form (vs. `sources:` shorthand) lets each child
    # inject an arbitrary tags map into every row it produces. We tag
    # with both a human-readable `cluster` name and the proxy `cluster_url`
    # so the destination drilldown source can derive its URL purely from
    # the selected row's data.
    pods_all:
      type: merge
      children:
        - source: pods_prod
          tags:
            cluster:     prod
            cluster_url: http://localhost:8001
        - source: pods_staging
          tags:
            cluster:     staging
            cluster_url: http://localhost:8002
        - source: pods_dev
          tags:
            cluster:     dev
            cluster_url: http://localhost:8003
      on_error: skip
      refresh: 5s

    # ── cross-cluster drilldown destinations ──────────────────────────
    # One source serves all three clusters because cluster_url is a
    # parameter. The bind: block at the push site (screens.pods.actions)
    # pulls cluster_url, namespace, name from the selected row.
    pod_detail:
      type: http
      parameters:
        cluster_url: { type: string, required: true, description: kube proxy base URL }
        namespace:   { type: string, required: true, description: pod namespace }
        name:        { type: string, required: true, description: pod name }
      url: ${params.cluster_url}/api/v1/namespaces/${params.namespace}/pods/${params.name}
      refresh: 5s
      timeout: 10s

    pod_logs:
      type: http
      parameters:
        cluster_url: { type: string, required: true }
        namespace:   { type: string, required: true }
        name:        { type: string, required: true }
      # follow: true → streaming log lines, one Event per line. Pair
      # with --raw from wrangl for plain text; the logview component
      # in this config consumes the Event stream natively.
      url: ${params.cluster_url}/api/v1/namespaces/${params.namespace}/pods/${params.name}/log?follow=true&tailLines=200
      follow: true
      format: text

tui:
  components:
    all_pods:
      type: table
      title: All pods (3 clusters merged)
      filterable: true
      source: pods_all
      initial_sort: {column: Cluster, desc: false}
      colors:
        border_active:    theme:accent
        header:           theme:accent
        selected_fg:      theme:bar-fg
        selected_bg:      theme:current
        column_separator: theme:subtle
        header_rule:      theme:accent
      columns:
        # Tags from merge land under the default `_meta` key, segregated
        # from the upstream pod fields so framework metadata doesn't
        # collide with kube data. Bind / column / inspector paths reach
        # them via dot-path (`_meta.cluster`, `_meta.cluster_url`).
        #
        # Both columns must be present so ${selection.Cluster} and
        # ${selection.ClusterURL} resolve at push time — Selection
        # substitution reads from column titles, not from underlying
        # JSON.
        - title: Cluster
          width: 10
          sortable: true
          value: _meta.cluster
          color_rules:
            - {when: prod,    color: red}
            - {when: staging, color: yellow}
            - {when: dev,     color: green}
        # ClusterURL kept narrow so it doesn't dominate the view. Could
        # be hidden in a future schema iteration (no "hidden column"
        # primitive today — Selection currently mirrors visible columns).
        - {title: ClusterURL, width: 22, value: _meta.cluster_url}
        - {title: Namespace,  width: 18, sortable: true, value: "metadata.namespace"}
        - {title: Name,       width: 38, sortable: true, value: "metadata.name"}
        # kubectl-style STATUS fallback chain — container waiting reason
        # beats terminated reason beats pod phase.
        - title: Status
          width: 18
          sortable: true
          value:
            - "status.containerStatuses.0.state.waiting.reason"
            - "status.containerStatuses.0.state.terminated.reason"
            - "status.phase"
          color_rules:
            - {when: Running,           color: green}
            - {when: Pending,           color: yellow}
            - {when: Succeeded,         color: theme:accent}
            - {when: Completed,         color: theme:accent}
            - {when: Failed,            color: red}
            - {when: Error,             color: red}
            - {when: CrashLoopBackOff,  color: red}
            - {when: ImagePullBackOff,  color: red}
            - {when: ErrImagePull,      color: red}
            - {when: ContainerCreating, color: yellow}
            - {when: Unknown,           color: gray}
        - title: Ready
          width: 6
          align: right
          value: "status.containerStatuses.0.ready"
          color_rules:
            - {when: "true",  color: green}
            - {when: "false", color: red}
        - title: Restarts
          width: 8
          sortable: true
          sort: number
          align: right
          value: "status.containerStatuses.0.restartCount"
          color_rules:
            - {when: "0",  color: gray}
            - {when: ">5", color: red}
            - {when: ">0", color: yellow}
        - {title: Node,    width: 24, value: "spec.nodeName"}
        - {title: Started, width: 22, sortable: true, value: "status.startTime"}

    pod_inspector:
      type: inspector
      title: ${selection.Name} — ${selection.Cluster}
      filterable: true
      initial_depth: 0
      source: pod_detail
      colors:
        border_active:   theme:accent
        label:           theme:muted
        value:           theme:bar-fg
        current_line_bg: theme:subtle
      fields:
        - {label: name,      path: "metadata.name"}
        - {label: namespace, path: "metadata.namespace"}
        - {label: nodeName,  path: "spec.nodeName"}
        - label: phase
          path: "status.phase"
          color_rules:
            - {when: Running,   color: green}
            - {when: Pending,   color: yellow}
            - {when: Succeeded, color: theme:accent}
            - {when: Failed,    color: red}
        - {label: podIP,  path: "status.podIP"}
        - {label: hostIP, path: "status.hostIP"}
        - label: metadata
          children:
            - {label: uid,               path: "metadata.uid"}
            - {label: creationTimestamp, path: "metadata.creationTimestamp"}
        - label: container[0]
          children:
            - {label: name,  path: "spec.containers.0.name"}
            - {label: image, path: "spec.containers.0.image"}

    pod_logs_view:
      type: logview
      title: ${selection.Name} — logs (live tail)
      searchable: true
      source: pod_logs
      color_rules:
        - {when: "~\\b(ERROR|FATAL|PANIC)\\b", color: red}
        - {when: "~\\bWARN(ING)?\\b",          color: yellow}
        - {when: "~\\bINFO\\b",                color: theme:accent}
        - {when: "~\\b(DEBUG|TRACE)\\b",       color: gray}
      colors:
        border_active:   theme:accent
        match:           theme:accent
        current_line_bg: theme:subtle
  screens:
    pods:
      title: All clusters
      layout: {component: all_pods}
      # On enter: push the detail screen with the focused row's cluster
      # URL, namespace, and name fed into the destination's params. The
      # same `pod_detail` / `pod_logs` source definitions handle every
      # cluster because cluster_url is just another bound param.
      actions:
        - key:    enter
          name:   open_detail
          push:   detail
          from:   all_pods
          bind:
            cluster_url: ${selection.ClusterURL}
            namespace:   ${selection.Namespace}
            name:        ${selection.Name}

    detail:
      title: ${selection.Cluster} / ${selection.Namespace} / ${selection.Name}
      layout:
        vstack:
          - {flex: 2, component: pod_inspector}
          - {flex: 3, component: pod_logs_view}
  initial: pods