Skip to content

perry/tui: ink-API ergonomics layer (useState, useEffect, useApp, JSX, source-compat) #679

Description

@proggeramlug

Background

`perry/tui` (#358, closed) already ships a complete native TUI engine inside `perry-runtime/src/tui/` — taffy-based flexbox, double-buffered ANSI diff with cell-level dirty tracking, ~3K LOC, zero npm deps. Widget set: `Box`/`Text`/`Spacer`/`Input`/`TextArea`/`List`/`Select`/`Spinner`/`ProgressBar`/`Table`/`Tabs`. Reactivity ships as `state(initial)` with `.get()`/`.set()` plus a global STATE_DIRTY flag that re-renders on change.

This issue covers what's left to make `perry/tui` feel like ink to authors — same hooks, same prop names, JSX-friendly — so ink's mental model and AI tools' code-generation habits transfer with minimal friction. The engine is done. This is API polish + missing hooks.

This is the recommended first-party TUI path; ink-via-`compilePackages` (#348) becomes a compiler smoke test only.

Goal

An ink program of the shape below compiles via `perry compile` with a one-line import change (`from 'ink'` → `from 'perry/tui'`) and runs natively:

```tsx
import { Box, Text, useState, useInput, useApp, render } from "perry/tui";

function App() {
const { exit } = useApp();
const [n, setN] = useState(0);
useInput((ch) => {
if (ch === "+") setN(n + 1);
if (ch === "-") setN(n - 1);
if (ch === "q") exit();
});
return count: {n};
}

render();
```

What ships today vs what's missing

Surface Today ink-shape target
`Box` / `Text` / `Spacer`
`Input` / `TextArea` / `List` / `Select` / `Spinner` / `ProgressBar` / `Table` / `Tabs`
flexbox props (`flexDirection`/`justifyContent`/`alignItems`/`gap`/`padding`/`flexGrow`|`Shrink`|`Basis`/pct)
`render(root)` / `enter` / `exit`
`useInput(handler)`
`state(initial)` builder Alias / wrap as `useState`
`useEffect(fn, deps)` Run after mount + on dep change; cleanup on unmount/dep-change
`useApp() → { exit, ... }` Imperative `exit()` from inside components
`useStdout() → { write, columns, rows }` Surface terminal dims, raw write escape hatch
`useFocus() → { isFocused }` + `` Tab navigation between focusable widgets
`useMemo(fn, deps)` / `useRef(initial)` Standard hook surface
JSX as authoring surface ⚠️ partial ``/`` lower to `Box(...)`/`Text(...)` — audit
Text styling props (`color`, `bgColor`, `bold`, `italic`, `underline`, `dimColor`, `inverse`) partial parity with ink
Source-compat test suite 5–10 small real-world ink programs as regression tests

Phases

Phase 1 — Hook API parity (highest leverage)

  • `useState(initial)` — alias to existing `state()` builder, but match React signature: returns `[value, setter]` tuple, not `{ get, set }` object. Internal slot allocation reuses `js_perry_tui_state_alloc`.
  • `useEffect(fn, deps?)` — runs after first render; if `deps` change between renders, re-runs (after running prior cleanup); cleanup returned from `fn` runs on unmount. Wire to render loop's frame boundary.
  • `useApp()` — returns `{ exit(code?), waitUntilExit() }`. `exit` flips a global flag the run loop checks at frame top.
  • `useStdout()` — returns `{ write(str), columns, rows }`. `write` for escape-hatch raw output; columns/rows from current terminal dims.
  • `useMemo(fn, deps)` / `useRef(initial)` — small additions, same slot machinery as `useState`.

Phase 2 — JSX audit + ergonomics

  • Verify `<Box flexDirection="row" gap={1}>...` lowers correctly through SWC's JSX transform to `Box({ flexDirection: "row", gap: 1, children: [...] })`.
  • Confirm children are passed through as an array, not spread positionally.
  • Document the JSX pragma / tsconfig setup for `perry/tui` in CLAUDE.md.

Phase 3 — Focus + keyboard navigation

  • `useFocus({ autoFocus?, isActive? })` — register the calling widget with a focus manager.
  • `` / `useFocusManager()` — Tab/Shift-Tab cycles focused widget; focused widget gets keypress events from `useInput`.
  • Required for any form-style ink program.

Phase 4 — Source-compat test suite

  • Take 5–10 small ink programs from the wild (counter, todo list, file picker, multi-step prompt, log viewer). Patch `from 'ink'` → `from 'perry/tui'`. Each compiles + runs + matches expected output. Lives in `test-files/test_perry_tui_inkcompat_*.ts`.
  • This is the acceptance gate — "ink-shape" is real when these compile.

Phase 5 — Text styling parity

  • Audit `<Text color="red" bold italic>`, ``, ``, `` against ink behavior. Should already mostly work via existing style.rs; this is a parity audit + filling in gaps.

Out of scope (deliberately)

  • React-reconciler compat — we don't ship a fiber tree, the architectural avoidance is the whole point of `perry/tui` (perry/tui: native TUI engine (zero-flicker, double-buffered, cell-level dirty tracking) #358).
  • Class components — ink dropped them too.
  • Devtools panel — ink's dynamic-import dependency.
  • `` ink component — niche, only needed for append-only scrolling output; defer.
  • Hooks-rules linting ("no hooks in conditionals") — we can reject loudly at compile time later if needed; not v1.

Acceptance

  • Phase 1 ships → counter program above (one-line import change from ink) compiles + runs interactively, matches expected behavior.
  • Phase 4 ships → all source-compat test programs green.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions