ratto 0.9.0

Ratatui-powered terminal primitives for shell dashboards: flicker-free repaints, progress bars, prompts, and portable time tools
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
# ratto

Ratatui-powered terminal primitives for shell dashboards. The binary is `rat`.

`ratto` is a small CLI in the spirit of [gum](https://github.com/charmbracelet/gum),
built for one job gum doesn't cover: **scripts that act as live dashboards** —
watching long-running jobs, rendering progress, and repainting flicker-free.
It keeps gum's scripting ergonomics (results on stdout, UI on the terminal,
meaningful exit codes) and adds the terminal-control plumbing you'd otherwise
hand-roll in every watcher script.

*Ratto* is Italian for rat — a nod to [ratatui](https://ratatui.rs), which
does the rendering under the hood (this project is not affiliated with
ratatui).

```sh
# The pitch, in one line: a flicker-free dashboard loop with zero escape codes.
rat watch --interval 2s -- ./render-status.sh
```

## Install

```sh
cargo install ratto
rat completion bash > ~/.local/share/bash-completion/completions/rat
rat completion fish > ~/.config/fish/completions/rat.fish   # zsh/powershell/elvish too
```

Works in any shell; examples here are plain bash, and [`examples/`](examples/)
has full scripts in bash, zsh, fish, and PowerShell. Synchronized-output repainting
uses terminal mode 2026 (Ghostty, Kitty, Alacritty, WezTerm, iTerm2, Windows
Terminal, …). Terminals without it just ignore the escapes — everything still
works. Check yours with `rat doctor`.

## The dashboard toolkit

### `rat watch` — run a command on an interval, repaint in place

```sh
rat watch --interval 2s -- ./status.sh        # flicker-free live view
rat watch --clear -- ./status.sh              # wipe the screen first, atomically
rat watch --once -- ./status.sh               # render one frame
rat watch --shell -- 'date; df -h | head -3'  # through sh -c
```

Cursor hiding, synchronized frames, redraw-only-on-change, height capping,
and terminal restore on exit/ctrl-c are all built in. Repaints rewrite
only the rows that actually changed, so steady dashboards stay calm —
and cheap over SSH. ANSI colors from the child pass through untouched.
Piped output degrades to plain text, so `rat watch ... | tee log` stays
readable. The interval is the quiet time between runs: a command slower
than its interval never overlaps itself — the next run simply waits its
turn.

Beside (or instead of) the interval, `--trigger` refreshes on an
external event. The sweet spot is two speeds — a slow heartbeat for what
only polling can see, a fast lane for what a file can announce:

```sh
# Refresh within a blink when state.json changes; poll the slow stuff
# (network checks, git state) once a minute.
rat watch -n 60s --trigger file:./state.json --trigger-debounce 1s -- ./render.sh

# Event-driven only: omit -n and nothing runs until something fires.
rat watch --trigger fifo:/tmp/rat.t -- ./render.sh   # echo go > /tmp/rat.t
```

Three sources, all repeatable: `file:PATH` stat-polls a file's mtime —
or a directory's, taken together with its immediate entries — and works
everywhere, including piped and on Windows; `fifo:PATH` reads a named
pipe you create with `mkfifo` (any write fires it, writers may come and
go); `fd:N` watches an inherited descriptor, composing with process
substitution (`--trigger fd:3 3< <(producer)`) and firing one last
notice when the descriptor ends. `fifo:` and `fd:` need an interactive
unix terminal; `file:` is the portable form.

Bursts collapse: `--trigger-debounce` (default `250ms`) turns any storm
of fires inside the window into one refresh, scheduled from the first
fire — an editor's multi-write save costs one run, and a file written
continuously still repaints once per window. A fire landing while a run
is already in flight never kills it; the fresh run starts the moment the
stale one finishes. With a trigger configured the bottom row reads
`every 60s or on trigger` (or just `on trigger`), and `?` lists the
configured sources.

Every tick, the child runs with `RAT_WIDTH` and `RAT_HEIGHT` set to the
current terminal size, so scripts can adapt their layout (branch on width,
or just pass `--fit` to `rat join`) and re-adapt live on resize. The child
also gets `RAT_APPEARANCE` set to the parent's light/dark verdict, so it
inherits the theme instead of asking the terminal itself — which it must
not do while `watch` owns the keyboard.

The command runs in the background of the watch's own loop, so every
key answers immediately — even while a slow command is still mid-run —
and the frame updates when the run finishes. While watching: `q` quits,
stopping the command it is running, and `v` (or Enter) opens the full
untruncated frame in your pager — resolved bat-style from `RAT_PAGER`,
then `PAGER`, then `less` (with `-R` ensured so colors survive; quit
the pager and the watch returns to the frame at once). On Windows, when
`less` isn't installed the stock `more.com` steps in. `?` pages the
full key reference the same way.

Every live frame's bottom row names the last time the output actually
changed and the refresh cadence, with `? help` as the one standing
hint: `since 14:03:52 · every 2s · ? help`. When output is taller than
the screen it merges into the truncation line:
`… 12 more lines · since 14:03:52 · every 2s · ? help`.

Scroll with less-style keys: `j`/`k` (or the arrows) move one line,
`d`/`u` half a window, `f`/`b` (or PgDn/PgUp) a full window, and `g`/`G`
(or Home/End) jump to the ends. The window moves over the live frame:
nothing pauses, new output keeps arriving under you, `G` sticks to the
end, and `g` (or scrolling back to the top) returns to the live view.
The bottom row names the range: `lines 9-30 of 46 · live`.
Scrolled lines render chopped, like a horizontally shifted view. If the
output changes shape while you're scrolled, the window rides along —
the row's total updates, a pinned window keeps tracking the end — and
if the moment you were reading slides away, step back to it with `<`.

Nothing ever pauses on its own. `p` parks the frame deliberately — the
command keeps running behind it, but nothing repaints over what you're
reading — and `Esc` or `F` return to the live tail. The paused row
stamps the moment the frame on screen was current:
`paused · at 14:03:52 · lines 2-23 of 30 · Esc resumes`. `q` quits and `S`
snapshots from either mode, and while paused `v` pages the frozen frame —
which is also where search lives: page into `less` and search there. One
deliberate divergence from `less`: Enter pages rather than scrolling one
line.

Step back in time with `<` (or `,`): each press parks on the previous
distinct frame, and `>` (or `.`) steps forward again. The paused row's
stamp says when the frame on screen was current, and `S` and `v` act on the
frame being viewed — step back to when it broke, press `S`. History
lives in memory only while the session runs, bounded to a few MiB of
distinct frames.

Each run's output is bounded too: rat keeps the newest 1000 lines of a
watched command and says so on the status row when it drops the rest.
The same bound applies to every dashboard pane, where `overflow`
decides which end survives; `rat dashboard` below states the rule.

`t` flips both time rows from wall-clock stamps to counting ages,
without changing what they mean: the live row's `since 14:03:52`
becomes `changed 14s ago`, and the paused row's `at 14:03:52` becomes
`14s ago`. One style at a time, on every surface — press `t` again to
flip back.

Two view toggles work live or frozen, without pausing anything: `w`
switches long lines between wrapped and chopped, and `h`/`l` (or
Left/Right) scroll the view horizontally in 8-column steps. As in `less`,
a horizontally shifted view shows chopped lines until you shift back to
the left edge. Start chopped with `--no-wrap`.

Two change markers show what moved against the previous distinct
frame, and both work live, scrolled, frozen, or stepped back in time —
a scrubbed frame shows what changed into it. `D` toggles a margin
column marking the changed lines; `c` highlights the changed
characters in place, in reverse video layered over the output's own
colors. Run either or both. The marks stay put until the output
changes again, so on a slow dashboard they answer "what moved last",
and they mark content only: a line that merely changed color stays
unmarked, and so does pure whitespace reshuffling, like a table
re-aligning its columns. A removed line leaves nothing to highlight,
so deletions show in the gutter only. The gutter is its own column —
it never scrolls away when you shift with `h`/`l`, and while it's on,
long lines render chopped, the same rule as a shifted view. The
character highlights follow the text wherever it goes: wrapped,
chopped, or shifted.

`S` writes the frame being viewed to `rat-watch-YYYYMMDD-HHMMSS.txt` in
`--snapshot-dir` (or `RAT_SNAPSHOT_DIR`, or the directory the watch was
launched from) and shows the path in the notice row. Snapshots are plain
text — ready for `grep` — unless `--snapshot-ansi` keeps the colors, and
a second snapshot in the same second gets a numbered name instead of
overwriting. The snapshot is the data, not the viewport: it always
contains the full untruncated frame, however the view is scrolled,
wrapped, or shifted.

### `rat dashboard` — N panes, N cadences, one frame

One command, one file, N panes composed into one flicker-free frame —
each pane running its own command on its own interval, with its own
triggers:

```sh
rat dashboard panes.kdl
rat dashboard panes.kdl --once   # render one frame and exit
```

The declaration file names each pane's command and cadence, shared
defaults, and where each pane sits, in KDL:

```kdl
gap 1

defaults interval="5s" border="rounded" padding="0 1" height=7

row {
    pane "log" {
        command "git" "log" "--oneline" "-3"
        interval "15s"
    }
    pane "branch" {
        command "git" "status" "--short" "--branch"
    }
}

pane "clock" {
    command "date" "+%H:%M:%S"
    interval "1s"
    height 4
}
```

A pane is declared inside the row or column that places it, so its name
is written once. `defaults` supplies anything a pane omits. A pane's
name is its identity — its default title, and the value of `RAT_PANE`
in the child's environment, so one script can serve every pane by
dispatching on it. `command` is a string split like a shell word list,
multiple arguments taken verbatim as argv, or a raw script string with
`shell #true`. For a script with backslashes in it — a `sed` program,
say — reach for KDL's raw strings (`command #"sed 's/\t/ · /'"#`) so
the escaping is the shell's job alone.

Every key a `pane` or `defaults` block accepts holds exactly one value,
so it may be written either as a property or as a child node —
`interval="5s"` and `interval "5s"` mean the same thing, and the
`defaults` line above is the property form of the same four keys.
`command` and `trigger` hold lists, and a KDL property holds exactly
one value, so those two are written as child nodes only.

Panes at the top level stack; a `row` puts them side by side. Rows and
columns nest to any depth, so grids need no second mechanism:

```kdl
row {
    column {
        pane "log" { … }
        pane "branch" { … }
    }
    pane "clock" { … }
}
```

`gap` is the columns between panes in a row, `row-gap` the blank rows
between rows, and `title` is the dashboard's own name — one bold line
above the composed panes, the same treatment `rat watch --title`
gives a plain frame. All three belong to the whole dashboard and are
written once at the top level. (`title` also exists as a pane key,
where it labels that one box's border — the file position keeps the
two meanings apart.) A declared title costs one row of the frame's
height budget, exactly like a pane row.

A pane may even run `rat dashboard … --once` as its child: the inner
one-shot sizes itself to the pane through the handed-down `RAT_WIDTH`/
`RAT_HEIGHT` and renders as a dashboard-in-a-dashboard, re-run at the
outer pane's cadence.

Per pane, `interval` takes a duration or `"never"` for a pane only a
trigger moves; `trigger` takes the same `file:` / `fifo:` / `fd:`
sources `rat watch --trigger` does, with `trigger-debounce` as its
window; a pane with neither runs every 2s. `height` pins the finished
box, borders and chrome included — the pin is what keeps the frame's
row count constant and repaints cheap. Longer output is cut by
`overflow`: `keep-top` (the default) or `keep-bottom` for a log tail.
`width` takes cells (`"40"`), a weight (`"2fr"`), or `"auto"`.

Every pane's last inner row is a faint `{cadence} · {stamp}` line the
loop owns. The stamp is when that pane's output last *changed*, not
when it last ran, so a calm dashboard stays calm; `t` flips every
time-bearing row — footer and panes together — to counting ages. A
pane that fails fails inside its own box: a spawn error renders as its
text, a nonzero exit shows the command's output and stderr with
` · exit N` on the chrome row, and the rest of the dashboard is
untouched.

There is no pane focus: every key acts on the whole dashboard, exactly
as in `rat watch` — freeze, scrub, snapshot, pager, scroll, and the
view toggles all work on the composed frame, and `?` pages the key
reference with each pane's cadence listed. `--once` runs every pane
once in parallel, prints one frame, and exits; piped output degrades to
plain text with each pane's stderr folded into its own box. If a pane
follows instead of exiting, `--once` says so on stderr after five quiet
seconds — naming the pane and the `live=#true` declaration to write —
and `--once-timeout 30s` bounds the wait: on expiry the run exits 124
with an empty stdout rather than printing a partial frame.

**Panes are for watching, not for doing.** A pane's command runs again
and again, and its declared interval is a floor rather than the whole
story — a pane also re-runs on every trigger that fires, on a debounced
respawn after the terminal is resized, and when the terminal switches
between light and dark, because a child already in flight was told the
old appearance. So "every 60s" is not 60 runs an hour; it is at least
that, on a schedule the dashboard controls and the command cannot see.

Write pane commands that can run at any moment and any number of times
without it mattering — read a file, query a status, format some text.
A command with side effects will have them again on events that have
nothing to do with its cadence. If a pane must touch something that
changes, have the command skip the write when nothing changed, and put
the part that cannot be skipped inside a script the dashboard only
*reads* the result of. Writing the same bytes again is not enough: a
`file:` trigger fires on modification time, not content, so a command
that rewrites a file identically — `cp`, `sed -i`, a formatter — fires
it every time. The same applies to a nested `rat dashboard … --once`:
the inner panes all run once per outer tick, and an inner `interval`
has nothing to schedule.

**A side effect on a watched path is a loop.** If a pane's command
touches a file that any pane triggers on — another pane's or its own —
then those panes drive each other for as long as the dashboard runs, at
a rate you did not choose. The frame itself will not say so: a pane's
stamp moves only when its output *changes*, so a loop whose output is
constant never repaints, and a dashboard can sit there spawning a shell
several times a second looking perfectly still. `interval "never"` is
not a brake — it removes the clock, and the trigger is what runs the
command. Point a trigger at a path no pane writes.

**rat says so when it notices.** A pane it suspects of looping carries
`· looping` on its chrome row, where a failing pane shows `· exit N`,
and the first time a loop is noticed one row names the panes involved
and the paths they watch, so you can check the claim against what you
declared. **Nothing is stopped.** Those panes keep running at whatever
rate they had — the report is a report. Press `?` for what the badge
means and both ways to fix it.

It can be wrong, and it can say nothing at all. rat cannot see who
writes a file. What it sees is that a watched path changes while the
dashboard is busy and never while it is idle, which is what a loop
looks like — and also what a pane fed only by other panes looks like.
A dashboard whose panes are busy most of the time has too little idle
time for that test to mean anything, so rat declines to answer rather
than guess, and a loop of slow commands is the kind it misses. Treat
the paragraph above as the fix and the badge as a warning you might
not get.

Cost, rather than correctness, has a lever: a pane declared
`interval "never"` with a `trigger` runs only when its trigger says
something changed, so an expensive command can sit behind a cheap file
whose modification time is the signal — written by something outside the
dashboard.

**Authoring for panes:** a pane's child prints *content only* — boxes,
titles, heights, and the side-by-side layout are the loop's job, so a
child that draws its own border just gets another drawn around it.
Each child is told its pane's inner size through `RAT_WIDTH` and
`RAT_HEIGHT` (and its name through `RAT_PANE`) and should format to
that width; height-stable output keeps repaints cheapest, which is
equally true for plain `rat watch` scripts — a placeholder row beats a
row that comes and goes.

**A command that never stops printing is bounded.** rat keeps at most
1000 lines of each run's output, per stream — a count of lines, never a
size, because a thousand short lines and a thousand long ones cost
about the same to hold and a byte budget would bound neither. Which end
survives is the pane's `overflow`: the head by default, the tail where
you declared `keep-bottom`. `rat watch` has no pane to declare it on and
keeps the newest, so a watch whose command floods now shows its tail
instead of everything it has ever printed.

Being a line count, it is not directly a memory bound: a single line is
kept up to 64 KiB before it is dropped whole, so the ceiling is 62.5 MiB
per stream rather than the ~100 KiB ordinary output costs. Terminal
lines run well under 100 bytes, so you would need output that is
uniformly enormous to approach it — but that is the number, and it
scales with the line count.

Past the bound, the pane says so on the chrome row where a failing pane
shows `· exit N`: `· 1.2k lines dropped`. A plain `rat watch` puts it on
the status row, and a piped run puts it on stderr, so the data you are
parsing stays the data. Press `?` for what it means. **Nothing is
stopped or slowed to make this happen** — rat reads the output to the
end and stops *keeping*, so a command never blocks writing into a pipe
nobody is draining.

**A pane can follow instead of poll.** `live #true` spawns the pane's
command once and paints its output as it arrives — the shape of
`tail -f`, `kubectl logs -f`, `docker logs -f` — instead of running it
on a cadence. The chrome row says `live` in place of an interval,
because there is none to report; the stamp still moves only when the
output changes. A live pane keeps the *tail* of its stream by default,
and `keep-top` is refused at load — a follower's newest line is the
point of it. The bound above applies unchanged, and a follower is the
shape most likely to reach it: a stream with no end meets a retained
set with one, and the pane wears the marker when it does.

`interval` still means something on a live pane — just not a cadence.
It is how soon a replacement spawns if the child ever exits: nothing
while the child runs, the delay before a fresh one when it dies. It is
deliberately not refused, because `defaults interval="5s"` would
otherwise be a load error for every live pane that inherits it, and a
mixed dashboard is the normal shape. `interval "never"` means no
replacement — the pane keeps its exit badge. A `trigger` on a live
pane *restarts* the child: the running one is killed and the
replacement spawned once it is reaped, debounced like every other
fire. Resizes and theme flips leave a live child alone — a follower
mid-stream is not restarted for cosmetics — so give it a `trigger` if
you want a handle to restart it by. `?` says all of this where the
chrome row has no room to.

**What `live` cannot fix: a pipeline that buffers.** The last stage of
a pipeline block-buffers its stdout when it is not a terminal, and
under rat it is not one. Measured plainly: `tail -f log` alone
delivered each line at its emission instant, while
`tail -f log | grep ERROR` delivered 0 bytes in 4 seconds — the same
log, the same appends. That is the stage's stdio buffering, not the
follower, and nothing rat does can change it. Give the stage a
line-buffered mode and the pipeline follows fine: `grep
--line-buffered`, `awk '{print; fflush()}'`, or `stdbuf -oL` in front
of a tool with no such flag.

A runnable declaration lives at
[`examples/panes.kdl`](examples/panes.kdl);
[`examples/panes-nested.kdl`](examples/panes-nested.kdl) shows nested
rows and columns and a dashboard-in-a-dashboard pane together, and
[`examples/follow.kdl`](examples/follow.kdl) is a live log follower
beside a batch pane. [`examples/tail.kdl`](examples/tail.kdl) is that
follower made self-feeding — the batch pane writes the log the live
pane tails, so it needs no second terminal — and
[`examples/tail-windows.kdl`](examples/tail-windows.kdl) is the same
dashboard for `cmd.exe`, where a `shell #true` script may contain
neither a double quote nor a pipe.

### `rat frame` — flicker-free repaint for script-owned loops

When you want your own loop, pipe each frame's content through `rat frame`:

```sh
while true; do
    {
        rat style --bold --foreground 212 'My Dashboard'
        rat bar --label build --value "$done" --total "$total"
    } | rat frame
    sleep 2
done
rat frame --finish   # show the cursor again when done
```

Unchanged frames write nothing; changed frames repaint in place; a terminal
resize forces a clean repaint. `rat frame begin` / `rat frame end` emit raw
synchronized-output escapes for full manual control.

### `rat bar` — progress bars without the arithmetic

```sh
rat bar --label 'release recovery' --value 1242 --total 1288 --state running
# release recovery                   ██████████████████████████████░░  1242/1288  96.4%  running
```

Batch mode reads `label<TAB>value<TAB>total[<TAB>state]` rows and aligns one
label column automatically:

```sh
printf 'build\t8\t10\ttests\ndeploy\t2\t10\twaiting\n' | rat bar --width 20
# build  ████████████████░░░░   8/10  80.0%  tests
# deploy ████░░░░░░░░░░░░░░░░   2/10  20.0%  waiting
```

An explicit `--label-width` pins the label column instead, so bars from
separate `rat bar` invocations line up too.

Color by completion band instead of picking colors in the caller, or animate
an unknown total:

```sh
rat bar --value 45 --thresholds '33:196,66:214,100:42'   # red → amber → green
rat bar --indeterminate --tick $i --width 16              # moving block
```

Presets: `--preset blocks|shade|ascii|line|dots`.

### `rat table` — columns without the arithmetic

A layout filter: tab-separated rows in, aligned columns out. Widths are
measured in display cells, so cells styled by `rat style` or `rat bar`
line up correctly — escapes are free and wide glyphs count double, which
is exactly what `column -t` and `printf '%-27s'` get wrong.

```sh
printf 'build\t8/10\tpassing\ndeploy\t2/10\twaiting\n' | rat table
# build   8/10  passing
# deploy  2/10  waiting
```

Per-column configuration is a positional comma list — an empty entry or a
short list keeps that column's default (auto width, left, truncate):

```sh
ps -o pid=,etime=,command= | tr -s ' ' '\t' |
    rat table --align r,r --widths ,,24
# 42  03:06  cargo nextest run --no-…
#  7  00:12  git push

printf 'Worktree\tfix/layout @ 47dfd63 with a very long description\n' |
    rat table --widths 10,28 --overflow ,wrap
# Worktree    fix/layout @ 47dfd63 with a
#             very long description
```

An explicit width is the column, so bars and tables from separate
invocations share an edge: `rat table --widths 27 --separator ' '` lines up
with `rat bar --label-width 27`.

### `rat join` — blocks side by side

Compose whole blocks: each positional argument (or `--file`, with `-` for
stdin) is a block, padded to its own widest line and joined row by row.

```sh
rat join --gap 2 "$(rat style --border rounded 'left panel')" \
                 "$(rat style --border rounded 'right')"
# ╭──────────╮  ╭─────╮
# │left panel│  │right│
# ╰──────────╯  ╰─────╯
```

Capture blocks with `"$(…)"` in bash/zsh, `(… | string collect)` in fish,
and `(… | Out-String)` in PowerShell. `--vertical` stacks instead, with
`--gap` blank lines between; `--align` takes top/middle/bottom beside and
left/center/right stacked.

Add `--fit` for responsive dashboards: when the joined width would exceed
the available width, the blocks stack vertically instead. Available width
resolves from `--max-width`, then `RAT_WIDTH` (which `rat watch` sets for
its children), then the terminal; with no signal at all the blocks stay
side by side, so plain pipelines remain deterministic.

### `rat spark` — sparklines

```sh
rat spark 3 1 4 1 5 9 2 6          # ▃▁▄▁▅█▂▅
seq 1 20 | rat spark --spark-color 212
```

### `rat duration` / `rat date` — time, portably

```sh
rat duration 5548                   # 1h 33m
rat duration --format clock 5592    # 01:33:12
rat duration --seconds 1h33m        # 5580

rat date --epoch 2026-07-26T12:00:00Z        # 1785067200 (replaces BSD date -j)
rat date --format '%l:%M %p' 1785067200      # 5:00 AM    (replaces date -r)
rat date --relative 1785067200               # in 2h 39m
rat date --since $start_epoch                # seconds elapsed, for ETA math
```

Same flags on macOS and Linux — no more `date -j -u -f '%Y-%m-%dT%H:%M:%SZ'`.

### `rat style` / `rat log` — styled text

```sh
rat style --bold --foreground 212 'Deploy status'
rat style --foreground '#04b575' 'ok'        # hex, 256 index, or names
rat log --level warn 'disk space low'        # WARN disk space low (stderr)
rat log --time '%H:%M:%S' --level info up    # timestamped
```

`style` also owns the box model — borders, padding, margin, a title in the
top border, and a pinned content width:

```sh
rat style --border rounded --title Deploy --padding '0 1' 'status: green'
# ╭─ Deploy ──────╮
# │ status: green │
# ╰───────────────╯
```

Borders come in `rounded`, `normal`, `thick`, `double`, and `ascii`;
`--border-color` styles the frame without touching the content, and the
title is inserted verbatim, so a pre-styled title
(`--title "$(rat style --bold Deploy)"`) keeps its own look. `--padding`
and `--margin` take CSS shorthand (`'1'`, `'0 2'`, `'1 2 3 4'`). With a
border, the painted width is the content `--width` plus horizontal padding
plus two. `NO_COLOR` governs color, not glyphs — borders keep their box
characters; `--border ascii` is the dumb-terminal opt-out. To draw a box
around *already styled* content (say, colored status lines), add
`--no-strip-ansi` so the input's own escapes survive the trip.

Colors survive command substitution — capability is detected from the
terminal, never from stdout, so `banner=$(rat style --bold hi)` keeps its
escapes even though stdout is a pipe. (This is the opposite of
`grep --color=auto`, on purpose: capturing styled text is the whole point.)

Under the default `--color auto`, output goes plain only when:

- there is no terminal at all — `/dev/tty` cannot be opened and stderr is
  not a tty (cron, CI runners, fully detached processes);
- `NO_COLOR` is set (wins over everything, including `CLICOLOR_FORCE`);
- `CLICOLOR=0` is set (unless `CLICOLOR_FORCE` overrides it);
- `CI` is set — CI logs are treated as not-a-terminal;
- `TERM` is `dumb` or names no color support — or, on unix, is unset
  (native Windows consoles never set `TERM` and get full color).

`--color always` and `--color never` beat the environment entirely: an
explicit flag outranks ambient variables, so `always` colors at full
`TERM` depth even under `NO_COLOR` or in CI, and `never` always strips.
To strip ANSI coming from *other* programs, pipe through a bare
`rat style`: input escapes are removed by default and an empty style adds
nothing back.

### Light and dark themes

`--appearance light|dark|auto` (global, default `auto`, also read from
`RAT_APPEARANCE`) selects the palette behind the semantic color tokens
below. Under `auto`, `rat` asks the terminal for its background color at
startup and falls back to `COLORFGBG`, then to dark. The question is
only asked when stderr is a terminal and the process is in the foreground,
so redirected or backgrounded runs simply use the fallback. Passing
`--appearance` alongside `--color never` (or under `NO_COLOR`) is accepted
and silently does nothing — output is plain either way, which composes
better in scripts than a warning would.

Every flag that takes a color (`--foreground`, `--background`,
`--border-color`, `--fill-color`, `--empty-color`, `--spark-color`, and
each half of `--thresholds`) accepts these token names in addition to
literal colors; each name resolves through the selected palette
(`on-accent` is black on the dark accent and white on the light one):

| Token | Meaning |
| --- | --- |
| `accent` | the brand highlight: bar fill and prompts |
| `on-accent` | text drawn *on* `accent` |
| `muted` | secondary text and the unfilled part of a bar |
| `border` | box and frame rules |
| `ok` | healthy / passing |
| `warn` | needs attention |
| `error` | failing |
| `debug` | the `DEBU` log tag |
| `info` | the `INFO` log tag |
| `fatal` | the `FATA` log tag |
| `selection` | the row under the cursor in `rat choose` and `rat filter` |
| `match` | the matched characters in `rat filter` |
| `cursor` | the `rat input` caret cell — the terminal's default foreground |
| `placeholder` | placeholder text in `rat input` and `rat filter` — the terminal's default foreground, drawn faint |

`cursor` and `placeholder` resolve to the terminal's default foreground in
both palettes, so naming them in `--foreground` yields uncolored text;
placeholder text is set apart by its faint attribute rather than a hue.

`--empty-color`'s default is the `muted` token rather than a literal
index, and `--fill-color`'s default is `accent`. `rat doctor` reports the
resolved appearance and where it came from, in both text and `--json`.

On unix, `rat watch` also follows the terminal while it runs. With
`--appearance auto` and a terminal that announces theme changes — Ghostty,
kitty, or tmux 3.7+ passing one through — switching your system or
terminal between light and dark repaints the dashboard and re-renders its
children in the new palette, without a restart. `rat` re-measures the
terminal's colors when it is told something changed, so a terminal whose
colors are pinned independently of the desktop theme keeps the palette
that matches what is actually on screen.

Opting out is the same pin as everywhere else: `--appearance light|dark`
or `RAT_APPEARANCE` fixes the palette for the run. Nothing is subscribed
to at all under `--color never`, `NO_COLOR`, `CI`, `--once`, or when
output is piped. Two limits worth knowing: a change that happens while the
pager (`v`) has the screen is picked up at the *next* change after you
leave the pager, and on Windows a `watch` session keeps the appearance it
resolved at startup. A change that lands while the frame is frozen is
adopted right away — a fresh run re-renders the output in the new
palette — but the frozen picture keeps its colors until you resume.

While the dashboard runs, `rat watch` asks the terminal to announce theme
changes, and tells it to stop before exiting — on `q`, Ctrl-C, or a
signal. If a session is killed outright (`kill -9`, a terminal window
crash), the terminal can keep announcing changes to whatever runs next;
`printf '\033[?2031l'` or `reset` clears it.

## Interactive prompts

The gum staples, rendering to `/dev/tty` so stdout stays clean:

```sh
fruit=$(rat choose apple banana cherry)
names=$(rat choose --no-limit alice bob carol)   # space selects, enter confirms
rat confirm 'Ship it?' && deploy                 # exit 0 = yes, 1 = no
name=$(rat input --placeholder 'Your name')
pw=$(rat input --password)
branch=$(git branch --format='%(refname:short)' | rat filter)
rat spin --title 'Building...' -- cargo build    # child exit code passes through
```

Exit codes everywhere: `0` success, `1` no selection / negative / error,
`2` usage error, `124` timeout (`--timeout 30s`, dashboard
`--once-timeout 30s`), `130` ctrl-c, and `rat spin` forwards the
child's code.

`rat spin` holds its child's output so it can replay it after the
spinner stops, and that is bounded too: **the newest 10,000 lines of
each stream**. Ten times what a watch pane keeps, because spin prints
what it kept rather than rendering a window over it — enough for a full
build log, and still a ceiling for a command that never stops. Ordinary
output costs about 1 MiB; the same 64 KiB-per-line rule puts the ceiling
at 625 MiB per stream for output that is uniformly enormous.

When it drops something it says so on stderr, never in the output you
are piping: `rat: 2.0k lines dropped from stdout — kept the newest
10000`. A stream you did not ask to see stays silent — `spin` drains
both pipes whatever the flags say, so the default invocation discards
plenty, and reporting output you never wanted would be debug noise on
every long-running command.

## A complete dashboard

```sh
#!/usr/bin/env bash
render() {
    rat style --bold --foreground accent 'Build pipeline'
    rat style --faint "$(date)"
    echo
    printf 'compile\t%s\t128\ntest\t%s\t96\n' "$compiled" "$tested" |
        rat bar --thresholds '50:warn,100:ok'
    echo
    rat log --level info "last artifact $(rat date --relative "$last_epoch")"
}

case "${1:-}" in
    --render) render ;;
    *) exec rat watch --clear --interval 2s -- "$0" --render ;;
esac
```

Runnable versions of this — plus the interactive prompts chained together —
live in [`examples/`](examples/) for bash, zsh, fish, and PowerShell. The
shell scripts are single-command watch dashboards: one script renders the
whole frame on one cadence. The declaration files beside them
(`panes.kdl`, `panes-nested.kdl`) are the other shape — N commands on N
cadences, composed by `rat dashboard`.

## Differences from gum

`rat` is not gum-complete, on purpose. It is gum's scripting primitives plus
the dashboard toolkit above.

- **Not ported:** `format`, `write`, `file`, `pager` — none of them earn
  their keep in a dashboard script.
- **Added:** `bar`, `spark`, `watch`, `dashboard`, `frame`, `doctor`,
  `duration`, `date`, `table`.
- **`rat table` is a layout filter**, not gum's interactive row picker — no
  selection or sorting, and per-column config is positional comma lists
  (`--widths 27,,8`).
- **Named colors are accepted** (`--foreground red`); gum silently drops
  them — and so are semantic token names (`accent`, `ok`, `warn`, …) that
  follow the terminal's light or dark background.
- **UI goes to `/dev/tty`** with an stderr fallback, so prompts survive
  `2>/dev/null`; gum writes UI to stderr only.
- **`rat filter` quits on one Esc press**; gum needs two.
- **`rat spin` uses pipes, not a PTY**; children that only colorize on a tty
  get `CLICOLOR_FORCE=1` instead.
- **`--color always` trusts `TERM`** even when piped, so forced color keeps
  its full depth in scripts and CI.

## Windows

ratto builds and runs on Windows (PowerShell, Windows Terminal, conhost,
or ssh'd into from any terminal). Native sessions get full color with no
`TERM` needed — a bare Windows console reports truecolor — and light/dark
is detected where the terminal answers the background query (Windows
Terminal does; others fall back to dark). The UI stream uses `CONOUT$`
where unix uses `/dev/tty`; `watch --shell` runs through `%COMSPEC% /C`;
`rat` enables VT processing on the console itself, so escapes are
processed even in legacy conhost, which simply ignores the synchronized-
output mode it doesn't implement (Windows Terminal supports it). Three
notes:

- The `v` key in `watch` prefers `less.exe` on PATH (Git for Windows,
  scoop, and winget all provide one) and falls back to the stock `more.com`,
  with the console held in UTF-8 while the pager runs so glyphs render
  correctly; set `RAT_PAGER` to override.
- `rat frame`'s default state file is keyed per terminal session; when
  running several dashboards in one console session, pass `--state`.
- Following the terminal's light/dark switch while `watch` runs is
  unix-only; on Windows a session keeps the appearance it resolved at
  startup.

## Exit codes

| Situation | Code |
|---|---|
| Success | 0 |
| Esc / nothing selected / `confirm` no / error | 1 |
| Usage error | 2 |
| `spin` child exited N | N |
| `--timeout` / `--once-timeout` expired | 124 |
| Ctrl-C | 130 |