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
23 changes: 23 additions & 0 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Bug report
description: Report reproducible Relay UI behavior.
body:
- type: textarea
id: behavior
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Include the smallest component, props, and terminal width that reproduces the issue.
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
description: Relay UI version, Node.js version, terminal, and operating system.
validations:
required: true
23 changes: 23 additions & 0 deletions .github/ISSUE_TEMPLATE/feature.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Feature request
description: Suggest a focused improvement to a shared component.
body:
- type: textarea
id: use-case
attributes:
label: Use case
description: Describe the terminal tool and what it currently has to draw by hand.
validations:
required: true
- type: textarea
id: behavior
attributes:
label: Proposed behavior
validations:
required: true
- type: checkboxes
id: scope
attributes:
label: Scope
options:
- label: This component would be useful to more than one Relay tool.
required: true
9 changes: 9 additions & 0 deletions .github/metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Repository metadata

- Name: `relay-ui`
- Display name: Relay UI
- Package: `@relay-cli/ui`
- Accent: paired
- Description: Shared Ink components for Relay terminal tools: theming, spinners, tables, and Markdown.
- Homepage: `https://github.com/relay-cli/relay-ui#readme`
- Topics: `typescript`, `react`, `ink`, `terminal-ui`, `tui`, `cli`, `components`, `developer-tools`
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
check:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24, 26]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run check
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Release package

on:
workflow_dispatch:

permissions:
contents: read

jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run check
- run: npm pack
- uses: actions/upload-artifact@v7
with:
name: relay-ui-package
path: '*.tgz'
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
dist/
coverage/
*.log
.DS_Store
.env
.env.*
!.env.example
tmp/
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/
coverage/
node_modules/
package-lock.json
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"singleQuote": true,
"bracketSpacing": false,
"trailingComma": "all"
}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

## Unreleased

- Added the package scaffold and the `relay-ui` component gallery.
- Added `relayTheme` and `monoTheme`, `defineTheme`, `resolveTheme`, `ThemeProvider` and `useTheme`, `Swatches`, and the gallery's `--theme` option.
- Added `Spinner` with `dots`, `line` and `pulse` styles or custom frames, `done` and `failed` states, and the `useSpinnerFrame` hook.
- Added `Table` with columns that fit the terminal width, end or middle truncation that counts wide characters, alignment, per-cell colors, a selected row, and `maxRows` scrolling.
- Added `Markdown`, moved from relay-chat, with formatting inside headings, list items and quotes, strikethrough, list start numbers, pipe tables drawn with `Table`, and `InlineText`, `CodeBlock` and the parsers on their own.
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Contributing

Install Node.js 22 or newer, then run:

```sh
npm install
npm run check
```

Changes should include tests for observable behavior. A component belongs in Relay UI once more than one Relay tool needs it. Each component gets a section in the gallery (`src/gallery/sections.tsx`) and an entry in [docs/api.md](docs/api.md).

Components take colors from the theme rather than hard-coding them, and they must render the same way in `renderToString` as in an interactive terminal, apart from animation.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
# Relay UI

Shared Ink components for Relay terminal tools.
Relay UI holds the Ink components that Relay's terminal tools share, so each tool draws tables, spinners, Markdown, and colors the same way instead of keeping its own copy.

## See the components

```sh
npm install
npm run demo
```

The gallery shows every component with sample data. Press `q` to quit. To print it once without opening the interactive view:

```sh
npx tsx src/cli.tsx --snapshot
```

## Use it in a tool

```sh
npm install @relay-cli/ui ink react
```

Ink and React are peer dependencies, so the components render with your tool's own copy of each.

## Develop

```sh
npm run check
```

`check` runs Prettier, ESLint, the TypeScript compiler, the Vitest suite, and the build. See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Security

## Reporting

Please report a vulnerability privately through GitHub's security advisory form for this repository. Include a minimal reproduction and the affected version.

## Terminal output

Relay UI renders whatever text a tool passes to it, including table cells and Markdown. Tools that display untrusted input, such as log lines, command output, or model replies, should strip terminal control sequences before rendering them so the input cannot move the cursor or change terminal settings.
114 changes: 114 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# API

Everything is exported from `@relay-cli/ui`.

| Export | Purpose |
| ---------------- | ----------------------------- |
| `relayUiVersion` | The installed package version |

## Theme

Components read their colors from the nearest `ThemeProvider`. Without one they use `relayTheme`.

```tsx
import {ThemeProvider, resolveTheme} from '@relay-cli/ui';

<ThemeProvider theme={resolveTheme(options.theme)}>
<App />
</ThemeProvider>;
```

| Export | Purpose |
| ---------------------------------- | ----------------------------------------------------------------------- |
| `relayTheme` | The Relay palette: orange accent, blue focus |
| `monoTheme` | The same palette with grey highlights |
| `themes` | Both themes by name |
| `resolveTheme(name, available?)` | Look up a theme by name; throws with the list of names when it is wrong |
| `defineTheme(name, colors, base?)` | A new theme that replaces only the colors given |
| `ThemeProvider`, `useTheme` | Set and read the current theme |
| `Swatches` | Every color in the current theme, for galleries and docs |

| Color | Used for |
| --------------- | -------------------------------------------- |
| `text` | Body text |
| `muted` | Labels, timestamps, hints |
| `accent` | The primary highlight, inline code, spinners |
| `focus` | Focus, selection, links |
| `success` | Success states |
| `warning` | Warnings |
| `danger` | Errors and failures |
| `rule` | Borders, separators, table rules |
| `surfaceRaised` | Selected rows, raised panels |
| `surface` | Panels and cards |
| `background` | The screen, for tools that paint one |

## Spinner

```tsx
<Spinner label="Thinking…" />
<Spinner label="Build" state={failed ? 'failed' : finished ? 'done' : 'running'} />
```

| Prop | Default | Meaning |
| ------- | -------------- | ---------------------------------------------------------------- |
| `label` | | Text after the spinner |
| `type` | `'dots'` | `'dots'`, `'line'`, `'pulse'`, or `{frames, interval}` |
| `state` | `'running'` | `'done'` shows ✓ and `'failed'` shows ✗; both stop the animation |
| `color` | theme `accent` | Color of the spinner |

`useSpinnerFrame(style, active?)` returns the current frame for tools that draw the spinner inside their own text. In `renderToString` and other single-frame renders the spinner shows its first frame.

## Table

```tsx
const columns: Column<Request>[] = [
{key: 'method', header: 'Method'},
{key: 'path', header: 'Path', truncate: 'middle'},
{key: 'status', header: 'Status', align: 'right', color: (row) => statusColor(row.status)},
{key: 'ms', header: 'Time', align: 'right', value: (row) => `${row.ms.toFixed(1)} ms`},
];

<Table columns={columns} rows={requests} width={columnsAvailable} selectedIndex={cursor} />;
```

Columns start as wide as their widest cell. When the table is wider than `width`, the widest columns shrink first, down to `minWidth` (the header width, at most 8), and cut text with `…`. Widths count East Asian wide characters and emoji as two columns.

| Prop | Default | Meaning |
| --------------- | -------------------- | ----------------------------------------------------------- |
| `columns` | required | Column definitions, below |
| `rows` | required | Any objects |
| `width` | `80` | The widest the table may be |
| `selectedIndex` | | Highlight one row in the focus color |
| `maxRows` | all | Show a window of rows around the selection, with ↑/↓ counts |
| `rule` | `true` | Draw a rule under the header |
| `emptyText` | `'Nothing to show.'` | Shown when there are no rows |
| `gap` | `2` | Spaces between columns |
| `rowKey` | row position | Stable React keys |

| Column field | Meaning |
| ------------ | ---------------------------------------------------------------- |
| `key` | Identifies the column; also the row property shown by default |
| `header` | Header text |
| `value` | `(row) => string` for computed or formatted cells |
| `width` | A fixed width that never shrinks |
| `minWidth` | The narrowest a shrinking column may get |
| `align` | `'left'`, `'right'` or `'center'` |
| `truncate` | `'end'` (default) or `'middle'`, which keeps both ends of a path |
| `color` | `(row) => color` for one cell, such as a status code |

The layout helpers are exported too: `layoutColumns(columns, rows, width, gap?)`, `fit(text, width, align?, truncate?)`, `truncate(text, width, mode?)`, and `visibleRange(count, maxRows, selectedIndex)`.

## Markdown

```tsx
<Markdown source={reply} width={columns - 4} />
```

`Markdown` renders headings, paragraphs, ordered and unordered lists, quotes, fenced code, pipe tables (drawn with `Table`), and rules, with bold, italics, strikethrough, inline code, and links inside any of them. An unclosed code fence runs to the end, so a reply that is still streaming shows its code as code. HTML, images, and nested lists are shown as text.

| Prop | Default | Meaning |
| -------- | --------- | ----------------------------------------------------------------------------------------------- |
| `source` | required | Markdown text |
| `width` | container | Columns to fill; text wraps and code boxes stretch to it. Rules and tables assume 80 without it |

The pieces are exported for tools that render Markdown their own way: `InlineText` (one run of inline Markdown), `CodeBlock`, `parseMarkdownBlocks(source)`, and `parseInline(source)`, with the `MarkdownBlock` and `InlineToken` types.
25 changes: 25 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
{
ignores: [
'dist/**',
'coverage/**',
'eslint.config.js',
'examples/**/*.mjs',
'tests/fixtures/**',
],
},
{
languageOptions: {
parserOptions: {projectService: true, tsconfigRootDir: import.meta.dirname},
},
rules: {
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/restrict-template-expressions': ['error', {allowNumber: true}],
},
},
);
Loading
Loading