# tui/: the stakeholder terminal surface

An Ink 7 + React 19 app, bundled by esbuild into one file (`dist/tui-app.js`)
that ships inside the humanish package and is loaded on demand by
`humanish tui`. Every other humanish command is built so an agent can drive it;
this is the one that takes the screen and waits for a person.

The bundle is a view layer. Everything humanish knows how to do is injected
across `src/tui/contract.ts` (repo root `src/`, not this workspace), so this
app never becomes a second implementation of "what is a run, which lab does it
belong to, is it alive" that can drift from `humanish runs`.

## Commands

From the repo root (pnpm workspace):

- `pnpm --filter humanish-tui typecheck`
- `pnpm --filter humanish-tui test` runs the navigation and text-golden tests.
- `pnpm --filter humanish-tui build` writes the bundle to `tui/dist/tui-app.js`.
- `pnpm tui:smoke` loads the built bundle and renders a real frame. It reads the repo root's
  `dist/tui-app.js` and `dist/tui/contract.js`, which only the root `pnpm build` writes, so run
  that first; the workspace build alone is not enough.
- `UPDATE_TUI_GOLDENS=1 pnpm --filter humanish-tui test` regenerates the goldens. Do it
  deliberately, and read the diff before committing.

## Layout

- `src/entry.tsx`: the bundle's only export (`startTui`); everything else is reached through
  it.
- `src/app.tsx`: data load, keyboard handling, and the frame chrome.
- `src/navigation.ts`: the screen stack as a pure reducer, so the whole navigation model is
  testable without a terminal.
- `src/screens/`: six screens. `labs`, `lab` and `run` are the main path; `all-runs` opens from
  the row below the last lab; `help` opens on `?`; `connections` opens on `c`.
- `src/testing/render-to-text.tsx`: the in-repo render harness.
- `tests/golden/*.txt`: committed frames at 80 and 45 columns.
- `build.mjs`: esbuild config, including the two bundling workarounds below.

## How to verify a change

A ladder. Start at the top; only descend when the rung above genuinely cannot
answer the question.

1. **Text goldens** (`tests/`) are the default, and where narrow-width work
   belongs. These render through real yoga layout at exact widths against a
   fake stdout, so they catch the entire class of layout bug (a status column
   colliding with a name, a path wrapping to two lines, a row overflowing a
   phone-width screen) with no terminal, no subprocess, and no sleeps. They
   wait on frame predicates, never timers. A fixed sleep captures whichever
   frame the scheduler happened to reach, which is how TUI suites become flaky.
   Run them at least once as `CI=true pnpm --filter humanish-tui test` before
   pushing. Ink asks `is-in-ci` whether the environment is interactive and, when
   it decides not, writes only the final frame at unmount, with no erase
   sequences and no intermediate renders. A suite that waits for frames then passes locally
   and fails every render test in CI. Both the harness and `entry.tsx` pass
   `interactive: true` to stop an environment variable deciding this, and a test
   pins it, but the habit is cheap and catches the next divergence of this kind.

2. **Bundle smoke** (`pnpm tui:smoke`, after the root `pnpm build`) loads the built
   artifact with fake TTY streams. This is the only thing that catches load-time failures, and
   load-time failures are the characteristic way a bundled Ink app breaks: it
   builds clean, passes every unit test, and dies the first time a user runs it.
3. **A real terminal** is only for "does this work for a human", never for
   layout. See the hazard below before reaching for tmux.

## The tmux hazard (read this before automating a real terminal)

On 2026-08-19 this workspace's own testing crashed the machine's tmux server
twice, taking every session on it down with it. The cause is understood and
worth not rediscovering:

- `window-size manual` makes the size of a new window come from `default-size`
  (see `man tmux`). tmux 3.4 walks that path in `clients_calculate_size` while
  the window does not exist yet and dereferences a NULL `struct window *`.
  Both crashes faulted at a byte-identical address, and it reproduces on an
  isolated socket in three commands, every time.
- `-g` is what made it catastrophic rather than local. `tmux set-option -t SESS
-g window-size manual` sets the option server-wide; the `-t` does not scope
  it. One server means every session on the machine dies together.
- The obvious replacement fails on a shared server. `new-session -x 36
-y 14` alone yielded a 59-column pane here, because the default `window-size
latest` sizes the window to the most recently active client and overrides
  `-x`/`-y`. Measure `#{pane_width}` before trusting a width.

So, if a real terminal is genuinely needed:

- Prefer a plain PTY over a multiplexer. A PTY has no server, no global option
  surface, and no blast radius beyond itself. This works today and needs
  nothing installed:

  ```bash
  timeout 25 script -qec 'stty cols 92 rows 30; node dist/cli.js tui' /dev/null
  ```

  The `stty` is not optional: `script` with no controlling terminal reports
  `isTTY=true` but `columns=0`, so a size set any other way is silently ignored.
  Use `node-pty` instead if a check needs to send keys and read frames back, and
  `@xterm/headless` if output ever needs parsing for colour, cursor or
  alt-screen assertions. The terminal-testing tools (xterm.js/VS Code, termless)
  converge on that pairing.

- If tmux is still the right tool, put it on its own socket
  (`tmux -L humanish-tui …`) so a tmux bug can only kill that server, and size
  windows with `resize-window -t <session> -x W -y H`, which is scoped to the
  window. Never `-g`.

## Rules

- Ink's `<Text>` props are not optional-with-undefined and this repo compiles
  with `exactOptionalPropertyTypes`, so pass colour through `src/text-props.ts`
  (`{...color(maybe)}`) rather than `color={maybe}`.
- Put spaces that matter in terminal output inside a string literal:
  `<Text>{"  none yet"}</Text>`. oxfmt, like Prettier, collapses runs of spaces in
  JSX text, which is harmless in a browser and changes an Ink layout.
- Measure terminal size from Ink's own `useStdout`, never from a prop. Two
  different stdout objects means laying out to one width and drawing into
  another, silently, and only visibly at narrow widths.
- Two bundling workarounds in `build.mjs` are load-bearing; removing either
  produces a bundle that builds and then crashes on first run:
  `react-devtools-core` is an optional peer Ink imports at module scope and
  must be stubbed (marking it `external` leaves a static import Node resolves
  eagerly), and several Ink dependencies are still CommonJS, so the ESM output
  needs a `createRequire` banner or esbuild's `__require` shim throws on
  `require("node:*")`.
- Keep the surface honest in the same way the evidence surfaces are: an unknown
  cost is "not recorded" and never `$0.00`, a statistic carries its denominator,
  truncation is visible, and a project that cannot be read says so instead of
  rendering an empty one.
