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