{"id":50655386,"url":"https://github.com/mellonis/flowtty","last_synced_at":"2026-06-07T23:30:59.625Z","repository":{"id":362184380,"uuid":"1257580598","full_name":"mellonis/flowtty","owner":"mellonis","description":"Build terminal apps in React: a react-reconciler host over Yoga flexbox that paints cells to a TTY, inline, or test backend.","archived":false,"fork":false,"pushed_at":"2026-06-03T02:17:53.000Z","size":1325,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-03T04:14:26.730Z","etag":null,"topics":["cli","flexbox","ink","react","react-reconciler","terminal","terminal-ui","tui","typescript","yoga"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mellonis.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-02T20:17:51.000Z","updated_at":"2026-06-03T02:17:57.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mellonis/flowtty","commit_stats":null,"previous_names":["mellonis/flowtty"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/mellonis/flowtty","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mellonis%2Fflowtty","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mellonis%2Fflowtty/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mellonis%2Fflowtty/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mellonis%2Fflowtty/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mellonis","download_url":"https://codeload.github.com/mellonis/flowtty/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mellonis%2Fflowtty/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34042554,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-07T02:00:07.652Z","response_time":124,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cli","flexbox","ink","react","react-reconciler","terminal","terminal-ui","tui","typescript","yoga"],"created_at":"2026-06-07T23:30:58.923Z","updated_at":"2026-06-07T23:30:59.614Z","avatar_url":"https://github.com/mellonis.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# flowtty\n\nA framework for building terminal apps in React. **M0:** a `react-reconciler`\nhost config over Yoga flexbox layout that renders `\u003cBox\u003e`/`\u003cText\u003e` to a cell\nbuffer and draws it to the terminal (or captures it via the test backend).\n\n\u003e The renderer is a host config on top of React's reconciler + Yoga — not a\n\u003e from-scratch renderer including layout, and not a performance competitor to\n\u003e native-core renderers like OpenTUI. flowtty's value is the app + workflow\n\u003e layers built on top (later milestones).\n\n## Status\n\nM1e (TTY frame diff). `TtyBackend` now writes only the cells that changed\nsince the previous frame. Adjacent changes on the same row share one cursor\nmove (the run flows contiguously). Style changes emit SGR only when the pen\nstate needs updating. **No-op repaints write nothing.** First frame + size\nmismatch + terminal resize fall back to a full redraw.\n\nThis is a perf-only change — no public API additions. Interactive apps\n(counter, prompt, form) that repaint per keystroke now issue a handful of\nbytes per frame instead of the full ~hundreds-of-bytes redraw.\n\n### Truecolor\n\n`Style.fg` and `Style.bg` accept:\n\n- Named colors (`'red'`, `'blue'`, `'white'`, …) — emit standard 30-37 / 40-47 codes.\n- 3-digit hex `#rgb` (each digit doubled — `#f80` → `#ff8800`).\n- 6-digit hex `#rrggbb`.\n- CSS-style `rgb(R, G, B)` (each channel 0–255 integer).\n\n24-bit color (`#…` / `rgb(…)`) emits `\\x1b[38;2;R;G;Bm` (fg) / `\\x1b[48;2;R;G;Bm` (bg).\nModern terminal required (iTerm2, Terminal.app, Windows Terminal, modern xterm).\nUnknown values are silently ignored.\n\n### Borders\n\n`\u003cBox border\u003e` draws a one-cell border on all four edges. The cells are reserved\nvia Yoga's per-edge border slots, so content fits inside the ring automatically.\n\n- `border=\"single\"` → `┌─┐ │ │ └─┘`\n- `border=\"double\"` → `╔═╗ ║ ║ ╚═╝`\n- `border=\"round\"`  → `╭─╮ │ │ ╰─╯`\n- `border=\"bold\"`   → `┏━┓ ┃ ┃ ┗━┛`\n- `border=\"classic\"` → ASCII fallback `+-+ | | +-+`\n\n`borderColor` accepts the same values as `color` (named, `#rrggbb`, `rgb(...)`).\nBoxes smaller than 2×2 silently skip the border.\n\n### Padding\n\n`\u003cBox\u003e` accepts CSS-style padding props. Per-edge wins over axis wins over shorthand.\n\n- `padding={n}` — all four edges\n- `paddingX={n}` — left + right\n- `paddingY={n}` — top + bottom\n- `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft` — per-edge override\n\nValues are integer cell counts. Padding and border combine — a `\u003cBox border=\"single\" padding={1}\u003e` insets content by 2 cells on each side (1 border + 1 padding). `backgroundColor` fills the full rect including padding cells.\n\n### Margin\n\n`\u003cBox\u003e` accepts CSS-style margin props. Same precedence as padding (per-edge \u003e axis \u003e shorthand).\n\n- `margin={n}` — all four edges\n- `marginX={n}` — left + right\n- `marginY={n}` — top + bottom\n- `marginTop`, `marginRight`, `marginBottom`, `marginLeft` — per-edge override\n\nValues are integer cell counts. Negative values are allowed — Yoga supports them for overlap layouts (a child with `marginLeft={-1}` shifts one cell into its preceding sibling's space).\n\n### Gap\n\n`\u003cBox\u003e` accepts CSS-style gap props for spacing between flex children.\n\n- `gap={n}` — both axes\n- `rowGap={n}` — vertical spacing (between rows / column-flex items)\n- `columnGap={n}` — horizontal spacing (between columns / row-flex items)\n\nPer-axis wins over shorthand. Gap applies BETWEEN siblings only — no extra space at the parent's leading or trailing edge. Often cleaner than per-child `marginRight`/`marginBottom` for evenly-spaced lists.\n\n### Flex sizing\n\n`\u003cBox\u003e` accepts the three flex sizing props:\n\n- `flexGrow={n}` — claim a share of leftover space (proportional weight; default `0`)\n- `flexShrink={n}` — claim a share of deficit when siblings overflow (proportional weight; default `0`)\n- `flexBasis={n | 'auto' | '50%'}` — initial size before grow/shrink applies (default `'auto'` — uses `width`/`height`)\n\n**Defaults match Yoga, not CSS.** CSS sets `flex-shrink` to `1` by default — flowtty (via Yoga) leaves it at `0`, so children overflow rather than shrink unless `flexShrink={1}` is set explicitly. Useful when overflow is intentional; surprising if you're used to CSS.\n\n### Flex wrap\n\n`\u003cBox flexWrap\u003e` controls multi-line flex layouts. Default `'nowrap'`.\n\n- `flexWrap=\"nowrap\"` (default) — single line; children overflow or shrink to fit\n- `flexWrap=\"wrap\"` — children flow to additional lines when they exceed the main axis\n- `flexWrap=\"wrap-reverse\"` — same as `wrap`, but wrap lines stack in reverse cross-axis order\n\nWhen wrap is on, `rowGap` controls spacing between wrap lines (perpendicular to the main axis); `columnGap` continues to control spacing between items on the same line.\n\n### Align content\n\n`\u003cBox alignContent\u003e` controls cross-axis distribution of wrap lines. Only effective when `flexWrap` is `'wrap'` or `'wrap-reverse'` AND the parent has more cross-axis space than the wrap lines need. Default `'flex-start'`.\n\n- `'flex-start'` (default) — lines packed at cross-axis start\n- `'flex-end'` — lines packed at cross-axis end\n- `'center'` — lines centered\n- `'space-between'` — first line at start, last at end, free space between\n- `'space-around'` — equal space around each line\n- `'space-evenly'` — equal space between all lines including edges\n- `'stretch'` — lines stretch to fill cross-axis space\n\nCSS deviation: CSS3 defaults `align-content` to `'stretch'` for flex; flowtty defaults to `'flex-start'` (deterministic, doesn't reflow content unexpectedly).\n\n### zIndex\n\n`\u003cBox zIndex\u003e` is an integer; higher values paint on top of lower within the same paint pass. Default `0`. Tree order is the natural tiebreaker (later sibling wins).\n\n**Does NOT cross pass boundaries.** Stack-flow children paint first, then absolutes — an absolute with `zIndex={0}` still overlays a stack-flow with `zIndex={999}`. zIndex only reorders siblings within the same pass.\n\n### Overflow\n\n`\u003cBox overflow\u003e` controls whether descendants are clipped to this box's content rect. Default `'visible'`.\n\n- `'visible'` (default) — descendants may extend past this box (current behavior)\n- `'hidden'` — descendants clipped to content rect; ALL descendant writes (backgrounds, borders, own-text, nested children) are gated\n\n`'hidden'` does NOT clip the box's own background or border — those are this box's own area, not its descendants' writes. Clips are intersected across nested `overflow: 'hidden'` ancestors.\n\n### Size constraints\n\n`\u003cBox\u003e` accepts four optional min/max size props that clamp Yoga's computed size:\n\n- `minWidth={n | '50%'}` — prevents flexShrink (and content) from shrinking below this\n- `maxWidth={n | '50%'}` — caps flexGrow (and explicit width) at this\n- `minHeight={n | '50%'}` — column-flex analog of `minWidth`\n- `maxHeight={n | '50%'}` — column-flex analog of `maxWidth`\n\nEach accepts a cell count or a percent string. Undefined = no constraint. Useful for responsive layouts (e.g. `maxWidth: '80%'` on a content panel) and for keeping flex-grow children from claiming all available space.\n\n### Aspect ratio\n\n`\u003cBox aspectRatio\u003e` is a number representing `width / height` (CSS convention). When one dimension is constrained (via `width`, `height`, or flex sizing), Yoga derives the other from the ratio.\n\n- `aspectRatio={2}` — twice as wide as tall (e.g., `width=10` → `height=5`)\n- `aspectRatio={0.5}` — twice as tall as wide (e.g., `height=4` → `width=2`)\n- `aspectRatio={1}` — square\n\nUseful for media-style panels where you want a fixed shape regardless of container size — e.g., a flex child with `flexGrow={1} aspectRatio={3}` claims leftover horizontal space and adjusts its height to maintain a 3:1 ratio.\n\n### Display\n\n`\u003cBox display\u003e` controls whether this box (and its subtree) participates in layout. Default `'flex'`.\n\n- `display=\"flex\"` (default) — normal flexbox participation\n- `display=\"none\"` — box and all descendants are removed from layout and skipped by paint. Siblings reflow as if this box didn't exist. React state is preserved (unlike conditionally unmounting).\n\nUseful for tab panels, collapsible sections, and conditional UI where remounting would lose form state, scroll position, or other ephemeral state.\n\n### Size awareness\n\nTwo complementary primitives for components that need to know their allocated space.\n\n**`onLayout` (per-box, nested-friendly):**\n\n```tsx\n\u003cBox onLayout={(rect) =\u003e {/* rect = { left, top, width, height } */}}\u003e\n```\n\nFires after layout with this box's computed rect. Use for components inside a flexbox layout (e.g. an `\u003cArticleReader\u003e` in a 70% panel needs to paginate against the panel's width, not the terminal's). **Diff before `setState`** — onLayout fires on every paint; unconditionally setting state with a new object infinite-loops:\n\n```tsx\n\u003cBox flexGrow={1} onLayout={(r) =\u003e {\n  if (!size || size.width !== r.width || size.height !== r.height) setSize(r);\n}}\u003e\n```\n\n**`useTerminalSize()` (whole terminal):**\n\n```tsx\nimport { useTerminalSize } from 'flowtty';\n\nfunction App() {\n  const { width, height } = useTerminalSize();\n  return \u003cBox width={width} height={height}\u003e…\u003c/Box\u003e;\n}\n```\n\nReturns the current terminal size; re-renders on `backend.onResize` (TTY) or initial-only (TestBackend / fixed-size). Useful for full-screen apps that own the terminal. For nested components, prefer `onLayout`.\n\n### Error handling\n\nflowtty wraps the user tree in a React error boundary AND registers process-level\n`uncaughtException` / `unhandledRejection` handlers. When ANY error is caught,\nflowtty calls `backend.dispose()` first (restores the terminal) and then either:\n\n- invokes the `onError` callback if provided in `render(element, backend, { onError })`, OR\n- prints the error to stderr and exits with code 1 (default).\n\n```tsx\nawait render(\u003cApp /\u003e, backend, {\n  onError: ({ error, source }) =\u003e {\n    // source: 'react' | 'uncaughtException' | 'unhandledRejection'\n    console.error(`[${source}]`, error);\n    process.exit(1);\n  },\n});\n```\n\nThe cleanup runs at most ONCE per render handle — subsequent errors after the\nfirst are ignored to avoid double-disposal. Process error listeners are removed\nwhen `handle.unmount()` is called, so multiple `render()` calls in sequence\n(e.g. in tests) don't leak listeners.\n\n**Logging errors to a file (development pattern):**\n\nflowtty intentionally doesn't bake file-logging into the default behavior — `onError` is the escape hatch. Common pattern for development:\n\n```tsx\nimport { appendFileSync } from 'node:fs';\n\nawait render(\u003cApp /\u003e, backend, {\n  onError: ({ error, source }) =\u003e {\n    const stamp = new Date().toISOString();\n    const trace = error instanceof Error ? (error.stack ?? error.message) : String(error);\n    appendFileSync('./flowtty-errors.log', `[${stamp}] [${source}] ${trace}\\n\\n`);\n    console.error(error);\n    process.exit(1);\n  },\n});\n```\n\nThen `tail -f flowtty-errors.log` in a second terminal during development. Adjust path / format / rotation per your needs.\n\nWithout this safety net, an unhandled error during render or in a useEffect would\nleave the terminal in alt-screen mode with raw input still enabled — recovery\nwould require killing the shell or running `reset`.\n\n### Root abort signal\n\n`useRootAbortSignal()` returns the render root's `AbortSignal` — the one flowtty\nfires (once) when the whole tree tears down, on both `handle.unmount()` and the\nerror path (just before `backend.dispose()`). It returns `null` when there's no\nflowtty `render()` in scope.\n\n```tsx\nimport { useRootAbortSignal } from 'flowtty';\n\nfunction Things() {\n  const signal = useRootAbortSignal();\n  const [data, setData] = useState(null);\n  useEffect(() =\u003e {\n    fetch('/things', { signal: signal ?? undefined })\n      .then((r) =\u003e r.json())\n      .then(setData)\n      .catch((e) =\u003e { if (e.name !== 'AbortError') throw e; });\n  }, [signal]);\n  // …\n}\n```\n\n**Why this instead of `useEffect` cleanup?** It isn't *instead* — they solve\ndifferent problems and are meant to be used together:\n\n- **`useEffect` cleanup (or a `cancelled` flag) is per-component.** It runs when\n  *this* component unmounts — a dialog closing, a list row scrolling out of the\n  window. Its job is to stop a stale `setState` from landing after the component\n  is gone. It does **not** cancel the underlying work; a `fetch` whose `.then`\n  is now a no-op is still holding a socket open.\n- **`useRootAbortSignal()` is whole-app.** It fires only when the entire render\n  root goes away (the process is exiting, or an error tore everything down). Its\n  job is to cancel work *at the I/O layer* so the runtime can actually shut down\n  — an in-flight `fetch` is aborted at the socket, a long timer's callback bails\n  — rather than leaving the event loop alive waiting on requests nobody will\n  read. It's also a ready-made cancellation token for anything that already\n  speaks `AbortSignal` (`fetch`, `addEventListener`, `setTimeout` via wrappers).\n\nSo: keep your effect cleanup for per-unmount correctness, and *also* forward the\nroot signal to async I/O for clean shutdown. The `things-tui` example wires both\n(see `ThingDetailView` — a `cancelled` flag for the dialog closing plus the root\nsignal on the `fetch`).\n\n**Composing with your own controller.** Since the hook returns a plain\n`AbortSignal`, you can merge it with controllers *you* own via `AbortSignal.any()`\n(Node 20.3+) — the combined signal aborts when *either* source fires. This is the\nclean way to fold \"this component unmounted\", \"the user hit cancel\", or \"the\nrequest timed out\" into the same token as \"the app is shutting down\":\n\n```tsx\nconst root = useRootAbortSignal();\nuseEffect(() =\u003e {\n  const local = new AbortController();              // per-unmount / cancel button\n  const signal = root ? AbortSignal.any([root, local.signal]) : local.signal;\n  fetch(url, { signal })\n    .then(setData)\n    .catch((e) =\u003e { if (e.name !== 'AbortError') throw e; });\n  return () =\u003e local.abort();                        // fires on THIS effect's cleanup\n}, [url, root]);\n```\n\nThat single combined signal makes the `fetch` abort on a per-component unmount\n(via `local.abort()` in the cleanup) *and* on whole-app teardown (via `root`) —\ncollapsing the two-layer pattern above into one cancellation token. Mix in\n`AbortSignal.timeout(ms)` the same way for a deadline.\n\n**It's a signal, not a controller.** The hook hands back an `AbortSignal`, which\nhas no `.abort()` — only flowtty's private controller can fire it. A component\ndeep in the tree can observe teardown (`.aborted`, `addEventListener('abort')`,\n`.throwIfAborted()`) or forward the signal, but it cannot abort the whole app.\n\n### Ticker (animation clock)\n\n`useTicker()` returns a frame counter that advances by one every `interval` ms.\nIt's the base clock under `\u003cSpinner\u003e`, `\u003cProgressBar\u003e`, and elapsed-time\ndisplays — anything that needs to repaint on a timer.\n\n```tsx\nimport { useTicker, Text } from 'flowtty';\n\nfunction Clock() {\n  const tick = useTicker({ interval: 1000 });   // one tick per second\n  return \u003cText\u003eelapsed: {tick}s\u003c/Text\u003e;\n}\n```\n\nOptions:\n\n- `interval` — milliseconds between ticks (default `80`, a common animation cadence).\n- `active` — when `false`, the ticker pauses and the count holds; flip back to\n  `true` to resume (it does not reset). Default `true`.\n\nThe interval is torn down on unmount **and** the instant the\n[root abort signal](#root-abort-signal) fires, so an animation can never keep\nticking — or keep the Node event loop alive — past the tree it belongs to. This\nis the reference implementation of \"an interval that respects the root signal\".\n\n### Spinner\n\n`\u003cSpinner\u003e` is an animated busy indicator built on `useTicker`. Mount it while\nwork is in flight; unmount it when done (the animation stops on unmount and on\nwhole-app teardown automatically).\n\n```tsx\nimport { Spinner } from 'flowtty';\n\n\u003cSpinner /\u003e                                  // default 'dots' set\n\u003cSpinner type=\"line\" label=\"Building\" /\u003e     // named set + trailing label\n\u003cSpinner frames={['🌑','🌒','🌓','🌔','🌕']} interval={120} color=\"cyan\" /\u003e\n```\n\nProps:\n\n- `type` — named frame set: `'dots'` (default), `'line'`, `'simpleDots'`, `'arc'`, `'circle'`.\n- `frames` — a custom frame list (overrides `type`); keep frames equal-width to avoid jitter.\n- `interval` — ms per frame (defaults to the chosen set's natural cadence).\n- `label` — optional text rendered one space after the glyph.\n- `color` — applied to the spinner glyph (named / `#rrggbb` / `rgb(...)`).\n\nThe frame sets are a curated subset of the cli-spinners catalogue, inlined so\nthe package stays dependency-free.\n\n### ProgressBar\n\n`\u003cProgressBar\u003e` is a determinate bar driven entirely by props — re-render with a\nnew `value` to advance it (it does not self-animate).\n\n```tsx\nimport { ProgressBar } from 'flowtty';\n\n\u003cProgressBar value={0.5} /\u003e                          // fills the row, 50%\n\u003cProgressBar value={3} total={4} width={20} showPercent /\u003e\n\u003cProgressBar value={done} total={files} color=\"green\" /\u003e\n```\n\nProps:\n\n- `value` — progress; a 0..1 fraction unless `total` is set, then it's `value / total`.\n- `total` — optional denominator; the fraction is clamped to 0..1.\n- `width` — fixed cell width. Omit to **fill the row** (measured via `onLayout`).\n- `char` / `emptyChar` — filled / empty glyphs (default `█` / `░`).\n- `color` — color of the filled portion.\n- `showPercent` — append a ` NN%` readout; in fill mode it reserves its own space\n  so the bar measures the remainder.\n\n### TaskList\n\n`\u003cTaskList\u003e` renders a vertical checklist where each task shows a state icon —\n`◌` pending, an animated spinner while running, `✓` success, `✗` error, `↓`\nskipped. It's data-driven: update a task's `state` and re-render to advance it.\n\n```tsx\nimport { TaskList } from 'flowtty';\n\n\u003cTaskList tasks={[\n  { label: 'Install deps', state: 'success' },\n  { label: 'Compile',      state: 'running' },\n  { label: 'Test',         state: 'error', detail: '2 failing' },\n  { label: 'Deploy',       state: 'pending' },\n]} /\u003e\n```\n\nEach `TaskItem` has a `label`, an optional `state` (default `'pending'`), and an\noptional `detail` (dimmed text after the label). `spinnerType` picks the spinner\nset used for running tasks. Running tasks animate via `\u003cSpinner\u003e` (and thus\n`useTicker`), so they stop cleanly on unmount / teardown.\n\n### Table\n\n`\u003cTable\u003e` draws a data grid: `data` rows × `columns` definitions, with\nbox-drawing rules (`border`) or whitespace (`border=\"none\"`).\n\n```tsx\nimport { Table } from 'flowtty';\n\n\u003cTable\n  data={[\n    { name: 'Ann', role: 'Engineer', age: 30 },\n    { name: 'Bo',  role: 'Designer', age: 27 },\n  ]}\n  columns={[\n    { accessor: 'name', header: 'Name' },\n    { accessor: 'role', header: 'Role' },\n    { accessor: 'age',  header: 'Age', align: 'right' },\n  ]}\n/\u003e\n```\n\nEach `TableColumn` has an `accessor` (a row key, or `(row, i) =\u003e string`), an\noptional `header` (defaults to the key), `align` (`'left'` | `'right'` |\n`'center'`), and `width` / `minWidth` / `maxWidth` bounds. Table-level props:\n`border` (`'round'` default, `'single'`, `'double'`, `'bold'`, `'classic'`, or\n`'none'`), `borderColor`, `cellPadding` (default 1), `showHeader` (default\n`true`), `headerColor`, and `headerBold` (default `true`).\n\n**Fit-to-width.** With no `width` prop the table measures its container (via\n`onLayout`, falling back to the terminal width before the first layout) and\nshrinks the widest columns — truncating cells with `…` — so the grid never\nexceeds the available space. Pass `width` to fix the total budget. Columns are\nonly shrunk, never stretched. Horizontal scroll for over-wide tables is a\nplanned follow-up (it needs a focus + keyboard surface).\n\n\u003e Column widths are measured in **code points**, matching flowtty's one-cell-\n\u003e per-code-point grid, so rules stay aligned. Double-width CJK/emoji cells carry\n\u003e the same visual overlap as the rest of flowtty until paint reserves the second\n\u003e cell (see *Display width* and *Still deferred*).\n\n### Markdown\n\n`\u003cMarkdown\u003e` renders a markdown string as styled terminal text. It's a\nbest-effort, line-based renderer — not a CommonMark implementation — covering\nthe subset that reads well in a cell grid:\n\n```tsx\nimport { Markdown } from 'flowtty';\n\n\u003cMarkdown\u003e{`\n# Heading\n\nA paragraph with **bold**, *emphasis*, \\`inline code\\` and a [link](https://x).\n\n- bullet one\n- bullet two\n\n\u003e a blockquote\n\n\\`\\`\\`ts\nconst x: number = 1;\n\\`\\`\\`\n`}\u003c/Markdown\u003e\n```\n\nStyle mapping (the terminal cell model has no italic — see [Text](#text)):\n\n| Markdown            | Rendered as                                  |\n|---------------------|----------------------------------------------|\n| `# … ######`        | bold + a per-level color, dim `#` prefix kept |\n| `**bold**`          | bold                                         |\n| `*emphasis*`        | underline (no italic in a cell grid)         |\n| `` `code` ``        | cyan                                         |\n| `[text](url)`       | blue + underline; url emitted as an OSC 8 hyperlink (clickable on capable backends) — see [Link](#link) |\n| `![alt](src)`       | dim `alt` text (images can't render in a TTY)|\n| `\u003e quote`           | dim, with a `│ ` gutter                      |\n| `- ` / `1. ` lists  | colored marker + hanging indent on wrap      |\n| ` ```lang ` fences  | per-language token colors (js/ts, json)      |\n| `---`               | a dim horizontal rule                        |\n\nEmphasis is **asterisk-only** on purpose: `_` is left alone so `snake_case`\nidentifiers in prose aren't mangled.\n\n**Why pre-wrap instead of leaning on Yoga's `flexWrap`?** The component lays the\nmarkdown out into a flat list of styled *visual lines* (`layoutMarkdown(src,\nwidth)`), each a run of styled spans, pre-wrapped to the resolved width. That\ngives a stable line count, so a host that paginates by row — like the\n`articles-tui` example, which slices the body by terminal height — can page\nthrough rendered markdown exactly the way it pages raw text. `layoutMarkdown` is\nexported for that use; `\u003cMarkdown\u003e` itself just measures its width (via\n`onLayout`) and renders every line. Pass an explicit `width` to skip the\nmeasure-and-relayout paint.\n\n\u003e The `articles-tui` example opens article `.md` files rendered this way by\n\u003e default; press `R` to flip to the **raw source view** — the markdown source\n\u003e shown verbatim but syntax-highlighted in place (markers kept and dimmed,\n\u003e headings/lists/links/fences colored, fenced-code token-colored). That view\n\u003e uses `highlightMarkdownSource(src, width, wrap)`, the source-preserving\n\u003e counterpart to `layoutMarkdown` (it never collapses whitespace, so code\n\u003e indentation survives, and it hard-wraps rather than word-wraps).\n\n### Link\n\n`\u003cLink href\u003e` renders a terminal hyperlink. On a backend that advertises the\n`hyperlinks` capability (the TTY backends, when the terminal supports it), the\nlabel is emitted as an [OSC 8](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)\nhyperlink — clickable (or ⌘/Ctrl-click) in supporting terminals. Where it can't\n(a plain pipe, the headless test surface, or a terminal that ignores OSC 8 such\nas Apple Terminal.app), it degrades to the styled label followed by a dim\n`(url)` so the address is still reachable.\n\n```tsx\nimport { Link } from 'flowtty';\n\n\u003cLink href=\"https://example.com\"\u003ethe docs\u003c/Link\u003e\n// capable terminal:  the docs        (clickable)\n// otherwise:         the docs (https://example.com)\n```\n\n| Prop              | Default  | Notes                                                        |\n|-------------------|----------|--------------------------------------------------------------|\n| `href`            | —        | Target URL. Control bytes are stripped before emission.      |\n| `children`        | `href`   | Visible label; falls back to the URL itself.                 |\n| `color`           | `'blue'` | Label color (always underlined).                             |\n| `showUrlFallback` | `true`   | Append ` (url)` when the backend can't render a clickable link and the label differs from the URL. |\n\n`hyperlinks` is a backend **capability flag** (like `fullScreen`): omitted means\n\"can't\", so `\u003cLink\u003e` degrades gracefully rather than promising a clickable link\nthe terminal won't honor. OSC 8 support is a property of the *terminal*, not of\nstdout being a TTY, and there's no escape-sequence query for it — so the TTY\nbackends sniff the environment (`TERM_PROGRAM` allowlist, `VTE_VERSION`,\n`WT_SESSION`, …; Apple Terminal.app is excluded). Override with\n`FORCE_HYPERLINKS=1` / `=0`. The painter always emits the OSC 8 bytes; the flag\nonly governs `\u003cLink\u003e` fallback (rendered-markdown links emit OSC 8 either way).\nThe URL rides in the cell `Style.link`, so it threads through the same paint +\nframe-diff path as visual attributes.\n\n### Display width\n\n`stringWidth(str)` / `charWidth(codePoint)` measure how many terminal **cells**\ntext occupies: 1 for most glyphs, 2 for East Asian Wide/Fullwidth and most emoji,\n0 for combining marks, zero-width formatters, and control bytes. The tables (the\nMarkus Kuhn combining set + the East Asian Wide/Fullwidth blocks) are inlined —\nno dependency. Use it to align columns or budget a row's width when laying out\nyour own content (it's the primitive the upcoming `\u003cTable\u003e` builds on).\n\n```ts\nimport { stringWidth } from 'flowtty';\n\nstringWidth('café');  // 4  (combining accent adds 0)\nstringWidth('日本語'); // 6  (each ideograph is 2)\nstringWidth('a😀b');  // 4\n```\n\nMeasurement is per-code-point, not grapheme-aware, so an emoji ZWJ sequence\n(👩‍👧) over-counts; pre-segment if you need cluster-exact widths. Expects plain\ntext (styling lives in the cell, not the string).\n\n### Still deferred (later milestones)\n\n- Wide-character **rendering**: the grid is still one cell per code point. The\n  backends back the cursor up one column after a double-width glyph (measured via\n  `stringWidth`) so the row stays column-aligned instead of shifting right — but\n  this overlaps the glyph's second column with the next cell. Cell-accurate\n  CJK/emoji layout waits on paint reserving the second cell.\n- Scrolling-region optimization for log-stream apps.\n- Column-only cursor moves (`CSI \u003ccol\u003eG`) when row is unchanged — small extra perf nibble.\n- Truecolor (`#rgb` / `rgb(…)`).\n- Explicit `zIndex` prop, `position: 'relative'`.\n- Bracketed paste, mouse, Kitty keyboard protocol, modifier-encoded arrows.\n\n### Usage with Zod\n\n```tsx\nimport { z } from 'zod';\nimport { useState } from 'react';\nimport { render, TextInput, Box, Text } from 'flowtty';\n\nconst Slug = z.string().regex(/^[a-z0-9-]+$/, 'kebab-case only');\n\nfunction App() {\n  const [v, setV] = useState('');\n  const validate = (x: string) =\u003e {\n    const r = Slug.safeParse(x);\n    return r.success ? null : r.error.issues[0]?.message ?? 'invalid';\n  };\n  return (\n    \u003cBox\u003e\n      \u003cTextInput value={v} onChange={setV} validate={validate} onSubmit={(s) =\u003e console.log('slug:', s)} /\u003e\n    \u003c/Box\u003e\n  );\n}\n```\n\n### DialogHost (stack)\n\n`\u003cDialogHost\u003e` lets components anywhere in its subtree open dialogs via\n`useDialogHost().openDialog(element)`. Each call **pushes** a new dialog on\ntop of the stack — previously open dialogs stay alive, render behind the new\none, and only receive input when they become the top of the stack again.\n\n`useDialog().done(value)` / `.cancel()` **pop** the top dialog, resolving the\n`openDialog` promise it returned. Lower stack entries are untouched.\n\n**Input gating:**\n\n- Host content's `useInput` is muted whenever ANY dialog is open.\n- Lower dialogs' `useInput` is muted while a higher dialog is on top.\n- Only the topmost dialog receives keys.\n\n**Caveat:** all dialogs share a single `dialogApi` instance — calling `done()` or `cancel()` always pops the TOP, regardless of which dialog component triggered it. Since input is gated to the top dialog, normal user-driven flows are safe; the edge case is async side-effects from a lower dialog (e.g. a useEffect / setTimeout) that calls `done` after a new dialog opened on top — it would pop the wrong entry. Wrap async work in `isMounted` guards if you need to be paranoid.\n\n### Focus + Button\n\nComponents inside a `\u003cFocusGroup\u003e` can call `useFocus()` to know if they're the active focusable. Tab cycles forward, Shift-Tab backward. First registered = auto-focused.\n\n`\u003cDialogHost\u003e` wraps each stack entry in an implicit `FocusGroup`, so Tab is scoped to the top dialog by default — no setup needed. Host content also gets its own implicit group.\n\n`\u003cButton\u003e` is focusable. Props:\n\n```tsx\n\u003cButton label=\"Open\" shortcut=\"o\" onPress={() =\u003e ...} /\u003e\n```\n\n- `Enter` when focused → `onPress()`\n- `shortcut` key (anywhere in the input scope) → `onPress()` even when not focused\n- Focused state: bold + inverse-video label\n\nTextInput / Select / MultiSelect also plug into the focus system. Their `isFocused` prop becomes optional — if unset, they read from the FocusGroup. If set explicitly, the prop overrides.\n\nOutside a FocusGroup, `useFocus()` returns `{isFocused: true}` (safe default — single component receives input as before).\n\n## Usage (M0)\n\n```tsx\nimport { createElement } from 'react';\nimport { render, Box, Text, TtyBackend } from 'flowtty';\n\nawait render(\n  createElement(Box, { flexDirection: 'row' },\n    createElement(Box, { width: 6 }, createElement(Text, null, 'hello')),\n    createElement(Box, { width: 6 }, createElement(Text, null, 'world')),\n  ),\n  new TtyBackend(),\n);\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmellonis%2Fflowtty","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmellonis%2Fflowtty","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmellonis%2Fflowtty/lists"}