Skip to content

tui: the lifecycle — every way out is one road, Static, print, the clock and progress; step 5 complete - #403

Merged
shreeve merged 20 commits into
mainfrom
tui/terminal
Sep 22, 2026
Merged

shreeve merged 20 commits into
mainfrom
tui/terminal

Conversation

@shreeve

@shreeve shreeve commented Sep 22, 2026

Copy link
Copy Markdown
Owner

PLAN step 5, both halves, reviewed cold and planned by three agents; every finding fixed and proven under a real job-controlled pty.

terminal.rip. One idempotent setup / teardown owns raw mode, bracketed paste, focus reports, the mouse modes, the kitty push and pop, the probes, the cursor, and the alternate screen (altScreen: true, ?1049h, frames absolute, left after the last frame). Every way out — quit, Ctrl-C, SIGINT / SIGTERM / SIGHUP (exit 128 + n), an uncaught error or unhandled rejection (exit 1), process.exit, beforeExit with a live app, a listener or frame that throws, a stdin whose raw mode cannot be set, a write that fails at close — takes the same road. The process handlers belong to the app's life: on at open (terminal only, ahead of the runtime's), off first at teardown, never touched by suspend or resume; a crash closes the app with its error (done rejects), removes our handlers, and leaves the error to the listeners after ours, once. A fuzz of keys, resizes, logs and suspends holds the terminal's modes to what the app believes after every step.

Suspend. Ctrl-Z is a default action of keydown (preventable): teardown, then SIGTSTP to the whole process group — what the tty driver does for a cooked ^Z — and only when the app reads the process's own stdin (a fake stream is not the terminal's job, so in-process tests take the same road without the signal). A stopped app holds the loop, so a timer-less app survives fg; on SIGCONT: setup, the origin asked again with the mouse, the kitty flag re-pushed only if it was pushed, pointer and parser reset, a whole repaint at the terminal's size. suspend(fn) is the same road without the signal. Keys left in the read that carried the ^Z are nobody's. Proven under test/terminal/ptyrun.py, a job-controlled pty shell checked in with the suite: ^Z stops the job leader (the bin/rip wrapper), %fg continues a key-driven app with no timer and it redraws whole and hears the next key, ^Z + SIGTERM + %fg exits 143 with the user's exit hook run, two stops and two continues exit 0. A stop signal to an orphaned process group is discarded by the kernel, so only such a shell can prove a stop; the earlier "for real" pin never did and is gone.

Output. Static paints each keyed child once, as its own rows at the terminal's width, written above the live frame and never again — a no-op on the alternate screen, written as it arrives off a terminal. print(text) and print.err write above the frame the same way; console output is captured by one node:console Console over two sinks, so every writing method (table, group, trace, assert, count, time*…) lands above the frame and the alternate screen replays it at leave. All three go through Screen.above: the frame's row moves down by the lines written, so a click after a log still lands on its row. clock(interval) is one shared timer per interval that runs only while a mounted component reads it and stops at close. screen.progress drives the terminal's own indicator (OSC 9;4), sent with the frame's write and cleared on every way out. Newline. renderToString answers the rows Static wrote and then the frame.

Off a terminal. No modes, no probes, no raw mode; the last frame is written once at exit, Static items as they arrive. Color depth is read once (screen.colors): NO_COLOR present and not empty is none; FORCE_COLOR 0 / false none, 1–3 that depth, true or empty 16; else by COLORTERM and TERM. Below full depth a color becomes the nearest of xterm's 256 by RGB distance (a color on the cube is sent as that point), and below that the nearest of xterm's 16 — a 256-entry table of xterm's own values, pinned at 33 points at both depths.

Held to Ink. 27 lifecycle titles held and 1 stated difference (test/terminal/SOURCE.md, recounted per file with sites and titles); 50 more paint cases in test/ink/static.rip (540 in the Ink suite, 521 of Ink's 632 titles); examples/log.rip is a build log — Static steps into the scrollback, a spinner on the clock, a progress bar, a print.

Also here: the Static cleanup reads its ref: cell directly (PR #402 made that sound) with a pin that a Static under a swapping if builds once; bin/rip maps a signal death to 128 + n.

Suites: test 129, text 49, layout 57, input 316, events 226, mouse 65, ink 540, yoga 543 (4 pinned), yoga-aspect 37, yoga-hand 53, fuzz 7, damage 6, terminal 78. The pty harness leaves no process behind (checked right after the suite and ten seconds later). bun run test:all: 29 lanes, 12,632 tests.

…ernate screen, non-TTY output, colors, the console
Ink's static-*.tsx, issue-973-static-commit.tsx and the Static cases of
components, render-to-string and background ported into
test/ink/static.rip, with cursor.tsx's useStdout and useStderr cases
through print; byte pins for the write above the frame, the mouse
origin after it, the clock's timer and the progress sequence in
test.rip; stubs so every file loads.
A Static item is laid out once as a root at the terminal's width,
painted to rows of its own and written above the live frame through
Screen.above — the frame's rows cleared, the rows written, the frame
drawn again below, in one write — then hidden, so the live frame never
holds it and the mouse origin moves by the rows written. print takes
the same road, print.err on stderr. clock(interval) is one timer per
interval that runs while a mounted component holds and reads it, on
the mount's own clock under mount. screen.progress reports OSC 9;4 with
the next frame's write, and Screen.clearProgress answers the clear for
the way out.

Grid.toString(true) leaves a row's trailing default-style blanks off,
as the plain form does, so a static row carries no padding into the
scrollback; the six pins that spelled those blanks out are updated.
Static's registration keeps its node as a plain value: a cleanup that
read the ref cell subscribed the disposing branch effect to it, and the
detach's clear built the replacement branch twice.
…spend, the alternate screen, non-TTY output, colors, the console

terminal.rip owns raw mode, the modes, the probes, the cursor, the
alternate screen, the signal, crash and exit handlers, and the console
capture, as one setup / teardown pair; tui.rip's listen, ask, answer
and close are thin over it, and `suspend` is exported. screen.rip
gains the `alt` flag and `leave` moves nowhere under it; paint.rip
emits colors at the depth `run` read, downsampling 24-bit colors to
the 256 and the 16.

Rows of the contract corrected while building, each with its reason:
- a log while suspended is the console's own, not relayed: teardown
  restores the console when the terminal is handed over
- the frame is cleared before warn and error too: the clear precedes
  the line whatever stream it goes to
- at 16 colors full-intensity red is bright red and full blue bright
  blue (91, 104): xterm's palette puts the full-intensity primaries at
  8 to 15
- the CI=false child is held to its frame count, not to contiguous
  text: a diffed frame writes only the changed cell
- a stderr stack need not name the script: what the runtime prints of
  a stack is the runtime's
- an unhandled rejection may let `done`'s continuation print before
  the exit: the runtime's microtask timing
- a listener hears Ctrl-Z before its default action, as it hears
  Ctrl-C
- a failure leaves whatever reached the screen: the fuzz demands the
  last frame only of the ways that draw it
examples/log.rip is a Static list of finished steps under a spinner and
a progress bar driven by one clock, a warning printed above the frame,
q to quit; test.rip runs it headless through mount and view.tick. A
written item its own component shows again is hidden again before the
next batch, and a print called with an item already in the tree comes
after that item. README gains Static output, Animation and Progress;
PLAN §3, §6 and §9 say what is built; TODO lists what is open.
…ve, progress cleared on every way out, the screen's own alt and interactive

A console line takes the road a Static item takes: Screen.above
clears the frame, writes the line to its stream, moves the frame's
row down by the lines written, and books the frame that draws it
again, so the clear, the line and the frame go out in one write.
terminal.rip's own clear-and-diff path is gone; under the alternate
screen a line is still kept and replayed once the screen is left, the
one case above does not carry. Teardown clears the progress
indicator after the last frame. The screen's `alt` and `interactive`
fields are the one record of both; off a terminal the frame writes
nothing and the last one is written once at exit through
rowsToString, so a Static item and a print are written as they
arrive. A frame booked before a close draws nothing after it, and
Clock.tick's `stop` no longer shadows the terminal's.

Pins re-spelled to above's coalesced write: a log's clear, line and
frame are one write on the next frame, which the line books; a
stderr line's clear goes out at once; with the mouse on, a log moves
the frame's row instead of asking for it again. Added: a Static item
and a print under the alternate screen and off a terminal, the log
example under both, the progress cleared on a signal, a frame booked
before a quit.
…ead of the runtime's, off first at teardown; a signal death maps to 128 + n in bin/rip
…ners after this package's, once; a signal's way out never throws
…le stopped, keys in the read, stops twice — and every state against every way out
… conventions read them, and every lowered color the nearest of xterm's own table
… Console of the runtime's own over two relaying sinks
…le do now, and SOURCE.md counts Ink's titles as it registers them
…s untracked; a Static under a swapping branch is built once
@shreeve
shreeve merged commit 2c6f1e6 into main Sep 22, 2026
2 checks passed
@shreeve
shreeve deleted the tui/terminal branch September 22, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant