Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0f01700
tui: the terminal contract — every way out, signals, suspend, the alt…
shreeve Sep 22, 2026
bf8803f
tui: the contract for Static, print, the clock and progress
shreeve Sep 22, 2026
0731163
tui: Static, print, the animation clock and progress
shreeve Sep 22, 2026
c3e8071
tui: terminal.rip — setup and teardown for every way out, signals, su…
shreeve Sep 22, 2026
dd1e178
tui: a teardown taken once shows the cursor once, below the frame
shreeve Sep 22, 2026
37f2761
tui: PLAN §8 as built, the README's Running section, and the TODO's o…
shreeve Sep 22, 2026
68d97d6
tui: a build log example, the docs, and two more Static pins
shreeve Sep 22, 2026
e4a05ff
tui: merge tui/static — Static, print, the clock, progress, rowsToStr…
shreeve Sep 22, 2026
656fb59
tui: one road above the frame — the console relays through Screen.abo…
shreeve Sep 22, 2026
e73a46b
tui: the process's handlers belong to the app's life — on at open, ah…
shreeve Sep 22, 2026
780ee5a
tui: a crash closes the app with its error and leaves it to the liste…
shreeve Sep 22, 2026
175a8dc
tui: what is left of a read once the terminal is another's is nobody's
shreeve Sep 22, 2026
c2cf5da
tui: a stopped app holds the loop until the continue, so a timer-less…
shreeve Sep 22, 2026
fb2df5e
tui: Ctrl-Z stops the whole process group, and only when the app read…
shreeve Sep 22, 2026
4a4c339
tui: a shell with job control for the tests — Ctrl-Z, fg, SIGTERM whi…
shreeve Sep 22, 2026
5bcbbac
tui: the depth from NO_COLOR, FORCE_COLOR, the terminal and CI as the…
shreeve Sep 22, 2026
863d91e
tui: every console method that writes goes above the frame, through a…
shreeve Sep 22, 2026
c7a85e8
tui: the docs say what a signal exit, a stop, the depth and the conso…
shreeve Sep 22, 2026
c659e1c
Merge remote-tracking branch 'origin/main' into tui/terminal
shreeve Sep 22, 2026
0f2ee99
tui: Static's cleanup reads its node directly, now that a cleanup run…
shreeve Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/rip
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { readFileSync, writeFileSync, existsSync, statSync, rmSync, mkdirSync, readdirSync, realpathSync } from 'fs';
import { spawn, spawnSync } from 'child_process';
import { randomBytes } from 'crypto';
import os from 'os';
import { join, dirname, resolve, relative, sep, delimiter } from 'path';
import { fileURLToPath } from 'url';
import packageJson from '../package.json' with { type: 'json' };
Expand Down Expand Up @@ -566,7 +567,7 @@ const run = (path, argv) => {
watchdog.unref();
return new Promise(() => {
child.once('error', () => process.exit(1));
child.once('exit', (code) => process.exit(code ?? 1));
child.once('exit', (code, signal) => process.exit(code ?? (signal ? 128 + os.constants.signals[signal] : 1)));
});
};

Expand Down
228 changes: 181 additions & 47 deletions packages/tui/PLAN.md

Large diffs are not rendered by default.

144 changes: 141 additions & 3 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,75 @@ run App
value given to `quit`. Ctrl-C quits. The last frame stays in the
scrollback and the cursor lands on the line below it.

## Running

`run App, options` takes the terminal for the app's life and gives it
back on every way out, by one road:

- **Exit.** `quit()`, Ctrl-C, a listener that throws, a frame that
fails, or a script whose loop drains: the last frame stays in the
scrollback, the cursor lands below it, and stdin, the terminal's
modes and the process's handlers are as they were. A failure leaves
what reached the screen, and `done` rejects with it.
- **Signals.** SIGINT, SIGTERM and SIGHUP give the terminal back and
exit with 128 plus the signal's number; a `done` an app awaits never
settles after a signal exit, since the process is gone before any
continuation runs. An uncaught error or an unhandled rejection gives
the terminal back, rejects `done` with the error, and leaves it to
the runtime — which prints it and exits 1 — or to the app's own
handler, once; a `process.exit` with the app live gives the terminal
back on the way.
- **Suspend.** Ctrl-Z gives the terminal back and stops the job as the
terminal would — the whole process group, so under `rip app.rip` the
shell sees one stopped job; `fg` draws the app again, whole, at the
terminal's size now, whether or not the app has a timer. It is a
default action of `keydown`, preventable like Ctrl-C. The stop
signal is sent only when the app reads the process's own stdin: with
any other stream — a test's, or a `stdout:` given with no stdin —
Ctrl-Z takes the same road and sends nothing, since a stream of one's
own is not the terminal's job, and whoever gave it continues the app
with `process.kill process.pid, 'SIGCONT'`. `suspend fn` is the same
road without the signal — the terminal is `fn`'s until it settles:

```coffee
import { suspend } from 'rip/tui'
suspend! -> Bun.spawn(['vim', path], stdio: ['inherit', 'inherit', 'inherit']).exited
```

A key that arrives meanwhile is nobody's; a `quit` meanwhile closes
the app without taking the terminal back.
- **Alternate screen.** `run App, altScreen: true` draws on the
terminal's alternate screen from its top-left; every way out leaves
it after the last frame, so the frame vanishes and the shell's own
screen comes back where it was. `Static` is nothing there.
- **CI and pipes.** On a stdout that is no terminal, or with `CI` set,
nothing is asked of the terminal and the last frame alone is
written, as text, at exit; `screen.interactive` reads false.
- **Colors.** The depth is read once at `run` and `screen.colors`
reads it: 0, 16, 256 or 16777216. `NO_COLOR` set to anything but the
empty string is none; `FORCE_COLOR` `0` or `false` is none, empty or
`true` the 16, a number that depth up to 3, any other word the 16;
otherwise a pipe, CI or a dumb terminal is none, `COLORTERM`
`truecolor` 24-bit, `TERM` `256color` 256, and any other terminal
16. A 24-bit color is drawn as the nearest of xterm's 256 — a color
on the cube as that point — and below that as the nearest of xterm's
16.
- **Console.** While the app runs, every console method that writes
(`log`, `table`, `group`, `trace`, `assert`, `count`, `time*`, …)
clears the frame, writes where it always went, and draws the frame
again below, so logs scroll into the scrollback above the app; on the
alternate screen they are kept and replayed at exit. `run App,
console: false` leaves the console alone.

## Widgets and styles

A box is a `div` and text is a `span`. `Box`, `Text`, and `Spacer` are
four-line components over them, and the raw tags are the zero-overhead
spelling of the same nodes. Every prop a widget does not declare is a
terminal style, forwarded to its node as written; bare text under a
box is a text leaf, and text nested in text restyles its own words.
`Newline count: n` is `n` line breaks inside text, and `Static` is the
scrollback (below).

| Moves boxes | Recolors cells |
|---|---|
Expand Down Expand Up @@ -227,6 +289,8 @@ view.send '\x1b[1;5A' # raw bytes, through the parser: keys, mouse reports
view.tick 50 # move the parser's clock: a lone ESC is Escape after 50 ms
view.focused # the node that has focus, or null
view.cursor # where the last frame parked the cursor, { x, y }, or null while hidden
view.scrollback # what `Static` and `print` wrote above the frame so far, as it was written
view.stderr # what `print.err` wrote
view.close() # unmount, and give the process its `document` slot back
```

Expand All @@ -250,7 +314,8 @@ mounted at a time: a second `mount`, `run`, or `renderToString` is
refused by name until the first is closed — close in a `finally`.

`renderToString App, cols: 40` is a mount, one frame, and a close; it
takes `props`, and `ansi: true` keeps the escape sequences. A child that
takes `props`, and `ansi: true` keeps the escape sequences. The rows
the app's `Static` items wrote come first, then the frame. A child that
fails to construct — at the mount, from a key, or from a state set by
the test — fails the mount, the key, or the next frame with the child's
own error, and a `done` is settled by `close` as well as by `quit`.
Expand Down Expand Up @@ -546,6 +611,69 @@ on:
- A lone Escape arrives 50 ms after the key, since ESC also opens every
sequence; under the enhanced keyboard it arrives at once.

## Static output

A log of finished work belongs in the scrollback, not in the frame.
`Static` around a keyed `for` writes each item once, above the live
frame, when it first appears — and never paints it in the frame, so
the frame stays the size of what is live.

```coffee
import { run, quit, print, Box, Text, Static } from 'rip/tui'

Build = component
@done := [] # the steps finished so far
@step := 'compile'
render
Box flexDirection: 'column'
Static
for name in @done
Text key: name, color: 'green', "✓ #{name}"
Text "… #{@step}"

build = run Build
build.app.done.value = ['resolve', 'fetch'] # two rows into the scrollback, the frame drawn again below
print 'warning: fetch took the slow road' # a line above the frame, the same way; print.err for stderr
```

An item is laid out at the terminal's width, with the items that arrive
in the same frame, in tree order; `Static`'s own props — `padding`,
`margin`, `backgroundColor` — go around each such batch. Once written,
an item is done: a change to its state or its removal from the list
changes nothing on the terminal. An item under a hidden ancestor waits
until it is shown. Off a terminal the rows go out as plain text as they
arrive; on the alternate screen nothing is written above. `examples/log.rip`
is a build log this way, with a spinner and a progress bar for the
step under way.

## Animation

`clock(interval)` is `{ frame, time, delta }` as reactive reads, moved
by one timer per interval: `frame` counts the intervals since the
timer started, `time` the milliseconds, `delta` the milliseconds since
the last tick. A spinner is `frames[tick.frame % frames.length]`.

```coffee
Spinner = component
tick = clock 80
render
Text color: 'cyan', "#{'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[tick.frame % 10]}"
```

The component that makes the clock in its body holds it, and every
component holding one interval shares its timer, which runs only while
one of them is mounted and has read it, and never off a terminal. Under
`mount` the clock runs on the mount's own time, so `view.tick 80` moves
the spinner a frame, as it moves the parser's waits.

## Progress

`screen.progress value` puts the app's progress on the terminal's own
indicator — the taskbar, the tab — through OSC 9;4, which Windows
Terminal, Ghostty, kitty and iTerm2 honor: a number from 0 to 1,
`'error'`, `'indeterminate'`, or `null` to clear. It goes out with the
next frame's write, and is cleared on every way out.

## What is here, and what is planned

[PLAN.md](PLAN.md) is the design and the order of work: the app
Expand All @@ -572,7 +700,9 @@ cell rounding, `if` / `else` and keyed `for` on a terminal, nested text
styles, hyperlinks byte for byte, `ref:` metrics, the grid diff replayed
through a terminal, 70,000 colors and 300,000 clusters through the
swept tables, a running app from first frame to `quit`, the `mount`
driver, and what each kind of change owes a frame, by its cells.
driver, what each kind of change owes a frame, by its cells, the bytes
of a `Static` write and of `print`, the mouse after one, the clock's
one timer and where it stops, and the progress sequence.
`test/damage.rip` changes random trees a step at a time and holds every
frame painted from its damage to the same tree painted whole, cell for
cell, and to the bytes sent, replayed. `test/text.rip` holds the
Expand All @@ -596,7 +726,15 @@ mode and under a frame taller than the terminal, the events and their
road, the modes' bytes on every way out, the probe and both answers,
the selection's cells, overlay, damage and clipboard bytes, and a fuzz
of random trees and random cells where the hit target must be the node
the painter put there. `test/yoga.rip` runs Yoga's
the painter put there. `test/ink/static.rip` holds Ink's `Static` cases
and its `useStdout` / `useStderr` cases through `print`
(`test/ink/SOURCE.md`). `test/terminal.rip` holds every way out to one
rule — Ink's suspend, exit, error, console and CI cases
(`test/terminal/SOURCE.md`), the signals and the crash in a spawned
process, Ctrl-Z and `suspend` byte for byte, the alternate screen, a
stdout that is no terminal, the color depth, the console, and a fuzz
of keys, resizes, logs and suspends that holds the terminal's modes
to what the app believes after every step. `test/yoga.rip` runs Yoga's
543 generated layout cases, vendored unmodified under `test/yoga/`
(MIT, © Meta Platforms), against the engine through a shim of the
`yoga-layout` API. `test/yoga-aspect.rip` is a port of Yoga's 37
Expand Down
26 changes: 18 additions & 8 deletions packages/tui/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ steps are in [PLAN.md](PLAN.md).
over the 300 updates of `bun run tui`, and about 170 µs over
12,000: the run ends while the damage path is still being
compiled. Warm the bench longer, or make the path smaller.
- [ ] A `Static` batch lays its container out as a root, and the
body's next layout puts the container away again by visiting
every item under it: a walk as long as the list, per batch. The
1,000-append row of the bench (PLAN §11) is unmeasured.
- [ ] `Static`'s items are elements; a bare text under `Static` is
never hidden and is painted again with every batch.
- [ ] `print` and a `Static` item write nothing on the alternate
screen, where Ink keeps them for the way out; console capture
(PLAN §8) decides what a run there keeps.

## 6. Input and focus

Expand All @@ -53,13 +62,6 @@ steps are in [PLAN.md](PLAN.md).
- [ ] A select list that marks its choice with one binding an item
(`inverse: n is at`) pays for every item on every arrow key: about
55 µs at a hundred items where ten cost 8 (`bun run keys`).
- [ ] `run`'s stdin and mode handling — raw mode, bracketed paste,
focus reports, the mouse modes, the cursor probe, the kitty
query and its pushed flag — is `listen`, `answer` and `close` in
`tui.rip`; it moves into `terminal.rip`'s setup and teardown with
the lifecycle (PLAN §8), where Ctrl-Z and the signals need it:
a suspend must pop the flag and withdraw the mouse, and a resume
ask for both again.
- [ ] After a resize the frame's row is asked of the terminal again
from what was the top-left; a terminal whose reflow moves the
cursor off that row places clicks wrongly until the next resize.
Expand All @@ -70,7 +72,15 @@ steps are in [PLAN.md](PLAN.md).
- [ ] `mouseenter` and `mouseleave` carry the terminal's cell and the
other target, not `x`, `y` from the corner of each node they reach.

## 7. Compiler-side, filed separately
## 7. The terminal

- [ ] A log while the console is captured is written to the run's
stdout, as Ink writes it, even when that is a stream of the
caller's own: a test whose app is still live when it fails hands
its own report to that stream. `test/terminal.rip` quits after
every test for that; the other suites do not.

## 8. Compiler-side, filed separately

- A typed vocabulary for non-HTML hosts, so `rip check` and the editor
accept terminal props (PLAN §14).
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/document.rip
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ unknown =! (key, tag) ->

# A reactive cell, minted on the first read of what it holds — a node's
# box, whether it has focus — so a node nobody asks carries none.
watch =! (first) ->
export watch =! (first) ->
held := first
{ read: (-> held), write: ((next) -> held = next) }

Expand Down
39 changes: 39 additions & 0 deletions packages/tui/examples/log.rip
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# A build log: each finished step scrolls into the scrollback above a
# live line that spins and fills a bar for the step under way, a
# warning is printed above the frame on the way, the terminal's own
# progress indicator follows, and the app quits when the last step is
# done — or on q.
#
# rip examples/log.rip

import { run, quit, screen, print, clock, Box, Text, Static } from 'rip/tui'

STEPS =! ['resolve', 'fetch', 'compile', 'link', 'bundle', 'sign']
SPIN =! '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
PACE =! 700 # milliseconds a step takes

export Log = component
tick = clock 80
step ~= Math.min STEPS.length, Math.floor(tick.time / PACE)
done ~= STEPS.slice 0, step
bar ~= '█'.repeat(step * 2) + '░'.repeat((STEPS.length - step) * 2)

~> print "warning: #{STEPS[2]} took the slow road" if step is 3
~> screen.progress (if step < STEPS.length then step / STEPS.length else null)
~> quit() if step is STEPS.length

render
Box flexDirection: 'column', focusable: true, autofocus: true, @keydown: ((event) -> quit() if event.key is 'q')
Static
for name in done
Text key: name, color: 'green'
"✓ #{name}"
if step < STEPS.length
Box flexDirection: 'row', gap: 1
Text color: 'cyan', "#{SPIN[tick.frame % SPIN.length]}"
Text "#{STEPS[step]}"
Text dimColor: true, "[#{bar}] #{step}/#{STEPS.length}"
else
Text color: 'green', bold: true, "all done"

run Log if import.meta.main
3 changes: 2 additions & 1 deletion packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
".": "./tui.rip"
},
"scripts": {
"test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip",
"test": "rip test.rip && rip test/text.rip && rip test/layout.rip && rip test/input.rip && rip test/events.rip && rip test/mouse.rip && rip test/ink.rip && rip test/yoga.rip && rip test/yoga-aspect.rip && rip test/yoga-hand.rip && rip test/fuzz.rip && rip test/damage.rip && rip test/terminal.rip",
"demo": "rip demo.rip"
},
"files": [
Expand All @@ -19,6 +19,7 @@
"text.rip",
"paint.rip",
"screen.rip",
"terminal.rip",
"input.rip",
"README.md",
"NOTICE"
Expand Down
Loading
Loading