@kud/ink-ui 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +117 -0
- package/README.md +8 -0
- package/dist/index.d.ts +41 -1
- package/dist/index.js +32 -2
- package/package.json +4 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Building a CLI with @kud/ink-ui
|
|
2
|
+
|
|
3
|
+
Guidance for an AI agent writing a terminal UI against this package. It carries
|
|
4
|
+
the judgement calls the type definitions cannot express — everything else is in
|
|
5
|
+
the types, which are always current.
|
|
6
|
+
|
|
7
|
+
## Read the types, not a list
|
|
8
|
+
|
|
9
|
+
**The exhaustive component surface is `dist/index.d.ts`.** Read it before
|
|
10
|
+
reaching for anything. This file deliberately does not list what exists: a
|
|
11
|
+
hand-maintained inventory goes stale, and a stale inventory is worse than none
|
|
12
|
+
because it tells you a component is missing when it is not.
|
|
13
|
+
|
|
14
|
+
**Before writing any component, check it isn't already here.** Bordered panes,
|
|
15
|
+
scrolling viewports, selectable rows, tables, tab bars, spinners, progress
|
|
16
|
+
bars, key/value pairs, badges, pills and footer key hints are all provided. A
|
|
17
|
+
hand-rolled version of one of these is the single most common mistake in a
|
|
18
|
+
consuming repo.
|
|
19
|
+
|
|
20
|
+
## The one rule that isn't in the types: who owns the keyboard
|
|
21
|
+
|
|
22
|
+
Components split into two kinds, and mixing them up is what produces a screen
|
|
23
|
+
that swallows keystrokes or responds twice.
|
|
24
|
+
|
|
25
|
+
**Uncontrolled — these call Ink's `useInput` themselves.** Mount at most one
|
|
26
|
+
per focus region, and gate the rest with `isDisabled` / `isActive`:
|
|
27
|
+
|
|
28
|
+
`Select` · `MultiSelect` · `TextInput` · `EmailInput` · `PasswordInput` ·
|
|
29
|
+
`ConfirmInput` · `ScrollView` · `UpdateBanner`
|
|
30
|
+
|
|
31
|
+
**Presentational — everything else.** They take `active` / `value` / `on` and
|
|
32
|
+
render. They never listen for keys, so they compose freely and you drive them
|
|
33
|
+
from your own state.
|
|
34
|
+
|
|
35
|
+
**The two hooks supply that state.** `useTabs(items)` and
|
|
36
|
+
`useListCursor(length)` own the keyboard so you don't hand-roll it — and both
|
|
37
|
+
take `{ isActive }` so a screen with several focus regions can gate them.
|
|
38
|
+
`useTabs` wraps by default (a tab bar is a ring); `useListCursor` clamps (a
|
|
39
|
+
list has ends) and takes `{ wrap }` when you genuinely want circular.
|
|
40
|
+
|
|
41
|
+
**Never write `useInput` to move a cursor or switch a tab.** That is what the
|
|
42
|
+
hooks are for, and hand-rolling it is how arrow/vim keys end up behaving
|
|
43
|
+
differently on every screen.
|
|
44
|
+
|
|
45
|
+
## Reaching for the right composition
|
|
46
|
+
|
|
47
|
+
| You need | Compose |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| A scrolling list of selectable rows | `useListCursor` + `SelectableRow`, one row per item |
|
|
50
|
+
| A long scrollable text/log region | `ScrollView` with `StyledLine[]` — it owns its own scroll keys |
|
|
51
|
+
| A tab bar | `useTabs` + `Tabs` — the hook holds `active`, the component renders it |
|
|
52
|
+
| Tabular data with aligned columns | `Table` with a `Column[]` spec — do not lay out columns by hand |
|
|
53
|
+
| Two or more side-by-side regions | `Columns`, and `Panel` for each region that needs a border |
|
|
54
|
+
| A focusable bordered region | `Panel` with `focused` — the border brightens and the title gains a ● marker |
|
|
55
|
+
| One-off prompt for a value | `TextInput` / `EmailInput` / `PasswordInput` / `ConfirmInput` |
|
|
56
|
+
| Pick one / pick many from a list | `Select` / `MultiSelect` — these own their keyboard, unlike `SelectableRow` |
|
|
57
|
+
| A persistent key-hints footer | `FooterHints` with `Hint` tuples: `[["↑↓", "move"], ["q", "quit"]]` |
|
|
58
|
+
| Label/value detail rows | `KeyValue` with a shared `labelWidth` so values align |
|
|
59
|
+
| A category label — `epic`, `draft` — that should read as one object | `Pill`, filled and rounded; `Badge` for the bracket form. Never for a reference the reader follows |
|
|
60
|
+
| Transient feedback | `StatusMessage` (inline) · `Alert` (boxed, with title) · `Toast` (self-dismissing) |
|
|
61
|
+
| App chrome | `Banner` at the top, `Header` per section, `LoadingScreen` while booting |
|
|
62
|
+
|
|
63
|
+
Composing a domain component on top of these is right and expected — wrapping
|
|
64
|
+
`Table` to render your own row shape is the system working. Reimplementing
|
|
65
|
+
`Table` is not.
|
|
66
|
+
|
|
67
|
+
## House rules
|
|
68
|
+
|
|
69
|
+
**Colour comes from tokens, never from a string literal.** Import `colors` and
|
|
70
|
+
use it. There are six tokens and only `accent` is a hex value — the rest are
|
|
71
|
+
named ANSI colours that adapt to the user's terminal theme:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { colors, spacing } from "@kud/ink-ui"
|
|
75
|
+
// colors.accent "#FF8C00" · muted · success · error · warning · info
|
|
76
|
+
// spacing.xs 1 · sm 2 · md 3 · lg 4
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
A literal like `color="orange"` or `color="#FF8C00"` is wrong even when it
|
|
80
|
+
renders identically — it breaks the moment a token moves.
|
|
81
|
+
|
|
82
|
+
**State is never signalled by colour alone.** Every status carries a shape, a
|
|
83
|
+
glyph or a weight as well, because a colourblind reader cannot see the hue and
|
|
84
|
+
a piped terminal has no colour at all. `SelectableRow` marks the active row
|
|
85
|
+
with `❯` *and* bold, not just a tint. Hold that line in anything you add.
|
|
86
|
+
|
|
87
|
+
**Set the icon mode once, before the first render.** `setIconMode("nerd")`
|
|
88
|
+
swaps in Nerd Font glyphs; the default `"text"` is safe everywhere. Components
|
|
89
|
+
read it at render time, so calling it after mounting does nothing.
|
|
90
|
+
|
|
91
|
+
**There is no theme provider and no context.** Components take only the props
|
|
92
|
+
they need. Do not build a provider to pass tokens around — import them.
|
|
93
|
+
|
|
94
|
+
**ESM only.** `import`, never `require`. Node ≥ 20, with `ink` ≥ 7 and
|
|
95
|
+
`react` ≥ 19 as peer dependencies the consuming project installs itself.
|
|
96
|
+
|
|
97
|
+
## Traps
|
|
98
|
+
|
|
99
|
+
- **A row that overflows its container compresses every flexible child.** If a
|
|
100
|
+
gutter or marker column must hold its width, wrap it in `<Box flexShrink={0}>`.
|
|
101
|
+
This only bites on content long enough to overflow, so it survives short test
|
|
102
|
+
fixtures and breaks in real use.
|
|
103
|
+
- **`Table` needs `maxWidth`** when it sits inside a bordered `Panel`, or the
|
|
104
|
+
columns size against the terminal rather than the pane.
|
|
105
|
+
- **`Toast` returns `null` once it has expired** — it unmounts itself, so don't
|
|
106
|
+
rely on it holding layout space.
|
|
107
|
+
- **`useTabs` returns `active` as possibly `undefined`** when the item list is
|
|
108
|
+
empty. Guard before indexing.
|
|
109
|
+
|
|
110
|
+
## Working on this repo
|
|
111
|
+
|
|
112
|
+
If you are editing ink-ui itself rather than building with it: components stay
|
|
113
|
+
presentational unless they are in the uncontrolled list above, every new
|
|
114
|
+
component needs a `.test.tsx` beside it, and the public surface is whatever
|
|
115
|
+
`src/index.ts` exports — a component not exported there does not exist.
|
|
116
|
+
`npm run demo` renders the gallery. Run `npm run typecheck`, `npm test` and
|
|
117
|
+
`npm run build` before committing.
|
package/README.md
CHANGED
|
@@ -39,6 +39,14 @@ npm install @kud/ink-ui
|
|
|
39
39
|
npm install ink react
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Building with an AI agent
|
|
43
|
+
|
|
44
|
+
The package ships `AGENTS.md`, a short brief covering the composition rules and house conventions that the type definitions cannot express — which components own the keyboard, what to compose for a given screen, and the traps. Point your agent at it:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
node_modules/@kud/ink-ui/AGENTS.md
|
|
48
|
+
```
|
|
49
|
+
|
|
42
50
|
## Usage
|
|
43
51
|
|
|
44
52
|
```tsx
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,46 @@ type BadgeProps = {
|
|
|
15
15
|
};
|
|
16
16
|
declare const Badge: ({ children, variant }: BadgeProps) => React__default.JSX.Element;
|
|
17
17
|
|
|
18
|
+
type PillVariant = "success" | "error" | "warning" | "info" | "accent" | "muted";
|
|
19
|
+
type PillProps = {
|
|
20
|
+
children: string;
|
|
21
|
+
variant?: PillVariant;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A filled, rounded label — a category the thing belongs to, not a status it is
|
|
25
|
+
* in.
|
|
26
|
+
*
|
|
27
|
+
* Reach for it when the word IS the information (`epic`, `draft`, `blocked`) and
|
|
28
|
+
* you want it to read as one object rather than as more prose. For a reference
|
|
29
|
+
* the reader is meant to follow — a ticket key, a repo — leave the text dim: a
|
|
30
|
+
* fill gives a breadcrumb a weight it has not earned, and once everything is a
|
|
31
|
+
* pill none of them is.
|
|
32
|
+
*
|
|
33
|
+
* The WORD carries the meaning and the colour only reinforces it, so a pill
|
|
34
|
+
* survives being read in monochrome, piped, or by someone who cannot separate
|
|
35
|
+
* the hues.
|
|
36
|
+
*
|
|
37
|
+
* Powerline half-circles, like `ToggleSwitch` and for the same reason: they are
|
|
38
|
+
* the only way to get a genuinely rounded end out of a terminal cell, and a font
|
|
39
|
+
* without them renders blanks, which degrades to a square pill rather than
|
|
40
|
+
* breaking the layout. Not gated on `getIconMode` — that switch chooses between
|
|
41
|
+
* two glyph SETS, and this is one glyph with a graceful absence.
|
|
42
|
+
*
|
|
43
|
+
* `NO_COLOR` is the one case that does fall back to brackets: with the fill
|
|
44
|
+
* stripped, the caps would be drawing the outline of a pill that is not there.
|
|
45
|
+
*/
|
|
46
|
+
declare const Pill: ({ children, variant }: PillProps) => React__default.JSX.Element;
|
|
47
|
+
/**
|
|
48
|
+
* How many columns `<Pill>` occupies for `text` — the label plus its two caps.
|
|
49
|
+
*
|
|
50
|
+
* Exported because a caller laying out a fixed-width row has to price the pill
|
|
51
|
+
* BEFORE rendering it, and measuring the rendered output is not available at
|
|
52
|
+
* that point. A row that budgets for the label alone overflows by exactly two
|
|
53
|
+
* columns, which in a frame sized to fill the terminal scrolls the whole panel
|
|
54
|
+
* instead of clipping.
|
|
55
|
+
*/
|
|
56
|
+
declare const pillWidth: (text: string) => number;
|
|
57
|
+
|
|
18
58
|
type HeaderProps = {
|
|
19
59
|
children: string;
|
|
20
60
|
subtitle?: string;
|
|
@@ -321,4 +361,4 @@ declare const spacing: {
|
|
|
321
361
|
};
|
|
322
362
|
type Spacing = (typeof spacing)[keyof typeof spacing];
|
|
323
363
|
|
|
324
|
-
export { Alert, Badge, type BadgeVariant, Banner, type Color, type Column, type ColumnAlign, type ColumnOverflow, Columns, ConfirmInput, EmailInput, FooterHints, Header, type Hint, type IconMode, KeyValue, LoadingScreen, MultiSelect, type NotifyOptions, OrderedList, Panel, PasswordInput, ProgressBar, ScrollView, Select, type SelectOption, SelectableRow, type Spacing, type Span, Spinner, StatusMessage, type StatusVariant, type StyledLine, Switch, type SwitchValue, type TabItem, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, setIconMode, spacing, useListCursor, useTabs };
|
|
364
|
+
export { Alert, Badge, type BadgeVariant, Banner, type Color, type Column, type ColumnAlign, type ColumnOverflow, Columns, ConfirmInput, EmailInput, FooterHints, Header, type Hint, type IconMode, KeyValue, LoadingScreen, MultiSelect, type NotifyOptions, OrderedList, Panel, PasswordInput, Pill, type PillVariant, ProgressBar, ScrollView, Select, type SelectOption, SelectableRow, type Spacing, type Span, Spinner, StatusMessage, type StatusVariant, type StyledLine, Switch, type SwitchValue, type TabItem, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, pillWidth, setIconMode, spacing, useListCursor, useTabs };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Box, Text, useStdout, useInput, measureElement } from 'ink';
|
|
2
2
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { glyphs } from '@kud/glyphs';
|
|
3
4
|
import React8, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect } from 'react';
|
|
4
5
|
import cliSpinners from 'cli-spinners';
|
|
5
6
|
import stringWidth from 'string-width';
|
|
6
|
-
import { glyphs } from '@kud/glyphs';
|
|
7
7
|
import { spawn } from 'child_process';
|
|
8
8
|
|
|
9
9
|
// src/components/Banner.tsx
|
|
@@ -39,6 +39,36 @@ var Badge = ({ children, variant = "info" }) => /* @__PURE__ */ jsxs(Text, { col
|
|
|
39
39
|
children,
|
|
40
40
|
"]"
|
|
41
41
|
] });
|
|
42
|
+
var FILL = {
|
|
43
|
+
success: colors.success,
|
|
44
|
+
error: colors.error,
|
|
45
|
+
warning: colors.warning,
|
|
46
|
+
info: colors.info,
|
|
47
|
+
accent: colors.accent,
|
|
48
|
+
muted: colors.muted
|
|
49
|
+
};
|
|
50
|
+
var INK = {
|
|
51
|
+
success: "black",
|
|
52
|
+
error: "white",
|
|
53
|
+
warning: "black",
|
|
54
|
+
info: "black",
|
|
55
|
+
accent: "black",
|
|
56
|
+
muted: "white"
|
|
57
|
+
};
|
|
58
|
+
var Pill = ({ children, variant = "muted" }) => {
|
|
59
|
+
const fill = FILL[variant];
|
|
60
|
+
if (process.env["NO_COLOR"]) return /* @__PURE__ */ jsxs(Text, { color: fill, children: [
|
|
61
|
+
"[",
|
|
62
|
+
children,
|
|
63
|
+
"]"
|
|
64
|
+
] });
|
|
65
|
+
return /* @__PURE__ */ jsxs(Text, { children: [
|
|
66
|
+
/* @__PURE__ */ jsx(Text, { color: fill, children: glyphs.plCapLeft }),
|
|
67
|
+
/* @__PURE__ */ jsx(Text, { backgroundColor: fill, color: INK[variant], children }),
|
|
68
|
+
/* @__PURE__ */ jsx(Text, { color: fill, children: glyphs.plCapRight })
|
|
69
|
+
] });
|
|
70
|
+
};
|
|
71
|
+
var pillWidth = (text) => text.length + 2;
|
|
42
72
|
var Header = ({ children, subtitle }) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
43
73
|
/* @__PURE__ */ jsx(Text, { bold: true, underline: true, children }),
|
|
44
74
|
subtitle && /* @__PURE__ */ jsx(Text, { dimColor: true, children: subtitle })
|
|
@@ -893,4 +923,4 @@ var notify = (message, options = {}) => {
|
|
|
893
923
|
child.unref();
|
|
894
924
|
};
|
|
895
925
|
|
|
896
|
-
export { Alert, Badge, Banner, Columns, ConfirmInput, EmailInput, FooterHints, Header, KeyValue, LoadingScreen, MultiSelect, OrderedList, Panel, PasswordInput, ProgressBar, ScrollView, Select, SelectableRow, Spinner, StatusMessage, Switch, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, setIconMode, spacing, useListCursor, useTabs };
|
|
926
|
+
export { Alert, Badge, Banner, Columns, ConfirmInput, EmailInput, FooterHints, Header, KeyValue, LoadingScreen, MultiSelect, OrderedList, Panel, PasswordInput, Pill, ProgressBar, ScrollView, Select, SelectableRow, Spinner, StatusMessage, Switch, Table, Tabs, TextInput, Toast, Toggle, ToggleSwitch, UnorderedList, UpdateBanner, colors, getIconMode, notify, pillWidth, setIconMode, spacing, useListCursor, useTabs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kud/ink-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "React component library for Ink CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"dist"
|
|
15
|
+
"dist",
|
|
16
|
+
"AGENTS.md"
|
|
16
17
|
],
|
|
17
18
|
"engines": {
|
|
18
19
|
"node": ">=20"
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"dev": "tsup --watch",
|
|
23
24
|
"demo": "tsx src/demo/index.tsx",
|
|
24
25
|
"typecheck": "tsc --noEmit",
|
|
26
|
+
"check:agents": "node scripts/check-agents-md.mjs",
|
|
25
27
|
"test": "vitest run",
|
|
26
28
|
"test:watch": "vitest"
|
|
27
29
|
},
|