Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ jobs:
- run: pnpm lint
- run: pnpm test

# Every `exports`/`bin` target exists in the built tree. A subpath nothing
# in the repo imports compiles clean and 404s for the first consumer.
- run: pnpm check:exports

# The playgrounds render in a pipe, with no terminal at all.
- run: pnpm dev gallery --static --width 100 --height 30
- run: pnpm dev charts --static --ascii --mono --width 100
60 changes: 60 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Release

on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
dry_run:
description: 'Pack and check, publish nothing'
type: boolean
default: true

permissions:
contents: read
id-token: write # npm provenance signs the tarballs with this

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile

# The same gate CI runs. A tag is not a reason to publish something that
# does not build.
- run: pnpm build
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test

# Every `exports` and `bin` target has to exist in the built tree. tsc
# cannot catch a subpath nothing in the repo imports.
- run: node scripts/check-exports.mjs

# The tag says what the packages must say. A tag that disagrees with
# package.json publishes a version nobody asked for.
- name: Check the tag against the versions
if: startsWith(github.ref, 'refs/tags/v')
run: node scripts/check-version.mjs "${GITHUB_REF_NAME#v}"

- name: Pack
run: pnpm -r --filter='!@textui/registry' --filter='!@textui/playground' exec npm pack --dry-run

# `pnpm publish -r` goes in dependency order and rewrites `workspace:^`
# to the real version. Private packages are skipped, which is what holds
# documents, textide and textide-git back.
- name: Publish
if: startsWith(github.ref, 'refs/tags/v') && inputs.dry_run != true
run: pnpm publish -r --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Changelog

The publishable packages release as a set, under one version. `workspace:^`
between them means a mixed set resolves to a combination nobody tested, so the
tag is the version and every package carries it.

This file records the set. Anything package-specific says which package.

## Unreleased

### 0.1.0 - the first publish

Six packages: [`textui`](packages/facade), [`@textui/core`](packages/core),
[`@textui/widgets`](packages/widgets), [`@textui/terminal`](packages/terminal),
[`@textui/testing`](packages/testing) and [`@textui/cli`](packages/cli).

`@textui/documents`, `@textui/textide` and `@textui/textide-git` are in the
repository and build in CI, but are held back from this release - they are
marked `private` until their surface settles, so `pnpm publish -r` skips them.

Pre-1.0: the surface is still moving.

#### Fixed before publishing

- `@textui/core` declared an `./hooks` export subpath pointing at
`dist/hooks/`, which is never emitted - hooks live in `runtime/hooks.ts` and
are already re-exported from the root. Nothing in the repository imported the
subpath, so nothing caught it; it would have been `ERR_MODULE_NOT_FOUND` for
the first consumer who tried it. The subpath is gone, and
`scripts/check-exports.mjs` now runs in CI so the next one fails a PR.
- Every package ships the MIT `LICENSE` in its tarball. `license: "MIT"` in the
manifest is not the licence text, and npm only includes a `LICENSE` that sits
in the package's own directory.
- Package READMEs linked to sibling packages relatively (`../core`), which
resolves in the repository and 404s on npmjs.com. They are absolute now.
- The documents guide said the JSON adapter ships in `@textui/core/adapters`.
It ships in `@textui/documents`; `core/src/adapters` is a deliberately empty
placeholder, and says so.
86 changes: 86 additions & 0 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Developing TextUI

For working *on* TextUI. To build something *with* it, start at
[`README.md`](README.md) and the [documentation](https://softov.github.io/textui/).

Node >= 22, pnpm 10.

## Getting set up

```bash
pnpm install
pnpm build # every package
pnpm typecheck # every workspace
pnpm test # every suite
pnpm dev --list # the playgrounds
pnpm dev gallery # open one
```

Releases are cut from a tag and publish as a set -
[`RELEASING.md`](RELEASING.md) is the runbook, [`CHANGELOG.md`](CHANGELOG.md)
the record.

The docs site is Jekyll, and needs no Ruby on your machine - it builds in a
container:

```bash
scripts/docs-serve.sh # live, with reload, at localhost:4000/textui/
scripts/docs-serve.sh --build # build once, into docs/_site
scripts/docs-preview.py # serve what was built, at localhost:8000/textui/
scripts/docs-preview.py --host 0.0.0.0 # ...and reachable from the network
node scripts/check-docs.mjs # the nav tree, links and titles
```

`docs-preview.py` exists because the site is built with `baseurl: /textui`, so every link in it is absolute at `/textui/...`. A plain `python -m http.server` over `docs/_site` 404s on all of it; this one mounts the site under the prefix the pages actually ask for.

Node ≥ 22, pnpm 10.

## The acceptance test

The three layouts this project started from - a dense bordered console, an airy borderless report, and a workbench frame - are one architecture with three registrations. `playground/test/playgrounds.test.tsx` mounts the same component under all of them, and under six themes, at three terminal widths, with and without Unicode and colour. If a shell ever needs a component the others cannot use, the boundary is in the wrong place.

## Why TypeScript, and how close it is to needing no build

Types are stripped rather than compiled now. Node has erased them since 22.6
behind a flag, and by default since 23.6 - so a `.ts` file with no non-erasable
syntax in it is a file Node runs. Nothing transpiles it; the annotations are
skipped the way a comment is.

That is the direction this library is aimed at. It has no dependencies, so the
only thing between the source and a `node` invocation is the syntax it uses -
and most of the syntax is already fine. Types, interfaces, generics,
`satisfies`, `as`, `import type`: all erasable, all stripped.

**What is not, here:** fourteen parameter properties (`constructor(private x: T)`)
across twelve files. That form declares a field *and* assigns it, so there is
runtime behaviour inside a type annotation and stripping cannot be correct.
Enums and value-carrying namespaces are the other two, and this codebase has
neither.

Setting `"erasableSyntaxOnly": true` in the tsconfig would make the compiler
refuse the non-erasable forms, turning this from an aim into a constraint. It
is not set yet, and the fourteen are still there - each one a mechanical
change, the field written out and assigned in the body. Treat this section as
the direction the library is aimed at, not a property it already has.

## Releasing

The publishable packages go out as a set, from a tag.
[`RELEASING.md`](RELEASING.md) is the runbook and [`CHANGELOG.md`](CHANGELOG.md)
the record. Two guards run in CI:

```bash
pnpm check:exports # every exports/bin target exists in the built tree
pnpm check:version 0.1.0 # the tag and the manifests agree
```

`check:exports` is there because tsc cannot catch a subpath nothing in the
repository imports - `@textui/core` shipped a broken `./hooks` for exactly that
reason, and it compiled clean the whole time.

## Conventions

The rules a change has to hold to are in [`CLAUDE.md`](CLAUDE.md): what lives in
`types/`, why registries are late-binding, why the store is the only state, and
how colour, glyphs and sizing work. A component that breaks one of those will
pass `pnpm test` and still be wrong.
73 changes: 23 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

A dependency-free TypeScript terminal UI runtime. Screens are plain data; JSX is one way to write them.

```bash
npm install textui @textui/widgets
```

```tsx
import { render } from 'textui';
import { registerBuiltins } from '@textui/widgets';
Expand All @@ -18,28 +22,16 @@ await waitUntilExit();

Everything else follows from that: one reactive store addressed by paths, typed registries for components, commands, themes, shells and resources, and a renderer that diffs cells rather than redrawing frames.

## Why TypeScript, and how close it is to needing no build

Types are stripped rather than compiled now. Node has erased them since 22.6
behind a flag, and by default since 23.6 - so a `.ts` file with no non-erasable
syntax in it is a file Node runs. Nothing transpiles it; the annotations are
skipped the way a comment is.

That is the direction this library is aimed at. It has **no dependencies**, so
the only thing between the source and a `node` invocation is the syntax it uses
- and most of the syntax is already fine. Types, interfaces, generics,
`satisfies`, `as`, `import type`: all erasable, all stripped.
## No dependencies

**What is not, here:** eleven parameter properties (`constructor(private x: T)`)
across ten files. That form declares a field *and* assigns it, so there is
runtime behaviour inside a type annotation and stripping cannot be correct.
Enums and value-carrying namespaces are the other two, and this codebase has
neither.
Nothing is installed alongside it. `@textui/core` has an empty `dependencies`,
and the packages above it depend only on each other - so what you audit is what
you get, and the tree does not grow behind your back.

`"erasableSyntaxOnly": true` in the tsconfig makes the compiler refuse the
non-erasable forms, so the constraint is enforced rather than remembered. The
eleven are a mechanical change - the field written out and assigned in the
body. Worth doing before it is worth claiming.
That also keeps the source close to running unbuilt: Node has erased types by
default since 23.6, and most of this codebase is already erasable syntax. The
full argument, and what is still in the way, is in
[`DEVELOPER.md`](DEVELOPER.md).

## Packages

Expand All @@ -51,6 +43,14 @@ body. Worth doing before it is worth claiming.
| [`@textui/terminal`](packages/terminal) | Terminal adapters, capability detection, ANSI writing, input decoding |
| [`@textui/testing`](packages/testing) | Headless harness: semantic queries, input, resizing, time |
| [`@textui/cli`](packages/cli) | `textui init / add / create / doctor`, and primitives for your own CLI |

Also in the repository, not yet published:

| Package | What it is |
| --- | --- |
| [`@textui/documents`](packages/documents) | Document buffers, resource viewers and content adapters |
| [`@textui/textide`](packages/textide) | An IDE that runs in a terminal, built on TextUI |
| [`@textui/textide-git`](packages/textide-git) | Git for textide, as a loadable extension |
| [`components/`](components) | The source-copy registry - components you own, not import |
| [`playground/`](playground) | The showcase, fourteen focused playgrounds, and a filesystem explorer |

Expand Down Expand Up @@ -122,37 +122,10 @@ Here's what TextUI actually looks like and what it can do.
<img src="./media/print-theme-mono.svg" alt="Terminal UI Mono" />
</p>

Now let's break down how it's built.

## Development

```bash
pnpm install
pnpm build # every package
pnpm typecheck # every workspace
pnpm test # every suite
pnpm dev --list # the playgrounds
pnpm dev gallery # open one
```

The docs site is Jekyll, and needs no Ruby on your machine - it builds in a
container:

```bash
scripts/docs-serve.sh # live, with reload, at localhost:4000/textui/
scripts/docs-serve.sh --build # build once, into docs/_site
scripts/docs-preview.py # serve what was built, at localhost:8000/textui/
scripts/docs-preview.py --host 0.0.0.0 # ...and reachable from the network
node scripts/check-docs.mjs # the nav tree, links and titles
```

`docs-preview.py` exists because the site is built with `baseurl: /textui`, so every link in it is absolute at `/textui/...`. A plain `python -m http.server` over `docs/_site` 404s on all of it; this one mounts the site under the prefix the pages actually ask for.

Node ≥ 22, pnpm 10.

## The acceptance test
## Developing

The three layouts this project started from - a dense bordered console, an airy borderless report, and a workbench frame - are one architecture with three registrations. `playground/test/playgrounds.test.tsx` mounts the same component under all of them, and under six themes, at three terminal widths, with and without Unicode and colour. If a shell ever needs a component the others cannot use, the boundary is in the wrong place.
Working on TextUI rather than with it - building, testing, the playgrounds, the
docs site and how a release is cut - is in [`DEVELOPER.md`](DEVELOPER.md).

## License

Expand Down
82 changes: 82 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Releasing

The publishable packages release as a set, under one version. They depend on
each other with `workspace:^`, so a mixed set resolves to a combination nobody
tested. The tag is the version; every publishable package carries it.

## What publishes

Six, in dependency order - `pnpm publish -r` works this out itself:

```
@textui/core
-> @textui/widgets, @textui/terminal
-> textui, @textui/testing, @textui/cli
```

`@textui/documents`, `@textui/textide` and `@textui/textide-git` are marked
`private` in their manifests. That is what holds them back, and it is the only
thing that does - a filter you have to remember is a filter that gets
forgotten. Remove `private` when their surface settles and they join the set.

## Once, before the first publish

The `@textui` scope is reserved. What is still needed:

- An npm **automation** token with publish rights on the `@textui` scope and on
the unscoped `textui` name, stored as the `NPM_TOKEN` repository secret:

```bash
gh secret set NPM_TOKEN # paste the token when prompted
```

It has to be an automation token, not a classic one - a token with 2FA on
publish cannot be used unattended, and the workflow has no way to answer the
prompt.

- The unscoped `textui` name confirmed as yours. The scope covers `@textui/*`
but not the facade package, which publishes as bare `textui`.

Nothing else is required: provenance is signed with the workflow's `id-token`
permission, which is already granted in `release.yml`.

## Cutting one

1. Land everything. `main` green.
2. Set the version on the six publishable manifests, and move the
`## Unreleased` heading in [`CHANGELOG.md`](CHANGELOG.md) down to the new
version.
3. `pnpm check:version <version>` - it fails if any of the six disagrees.
4. Tag and push:

```bash
git tag v0.1.0
git push origin v0.1.0
```

The `Release` workflow runs the same gate CI runs - build, typecheck, lint,
test - plus `check:exports` and the tag/version check, packs every package, and
only then publishes with provenance.

## Rehearsing one

The workflow takes a manual `workflow_dispatch` with `dry_run` on by default:
it runs the whole gate and packs every tarball, and publishes nothing. Use it
before the first real tag.

Locally:

```bash
pnpm build && pnpm check:exports # what a consumer will actually resolve
pnpm -r exec npm pack --dry-run # what is in each tarball
```

## What the guards are for

- **`scripts/check-exports.mjs`** - every `exports` and `bin` target exists in
the built tree and is covered by `files[]`, and every package has a README
and a LICENSE. tsc cannot catch a subpath nothing in the repository imports;
`@textui/core` shipped a broken `./hooks` for exactly that reason. Runs in
CI on every pull request.
- **`scripts/check-version.mjs`** - the tag and the manifests agree. Runs in
the release workflow, on tag only.
2 changes: 1 addition & 1 deletion docs/documents/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ registration.dispose(); // removes exactly what it added
`register(app)` escape hatch for anything the fields cannot express. They are
registered in that order, so a viewer always has its kind to match against.

The JSON adapter shipped in `@textui/core/adapters` is the worked example:
The JSON adapter shipped in `@textui/documents` is the worked example:

<!-- docs:nocheck -->
```ts
Expand Down
Loading
Loading