@kud/ink-ui 0.14.1 → 0.16.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 +116 -0
- package/README.md +8 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +94 -6
- package/package.json +6 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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 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
|
+
| Transient feedback | `StatusMessage` (inline) · `Alert` (boxed, with title) · `Toast` (self-dismissing) |
|
|
60
|
+
| App chrome | `Banner` at the top, `Header` per section, `LoadingScreen` while booting |
|
|
61
|
+
|
|
62
|
+
Composing a domain component on top of these is right and expected — wrapping
|
|
63
|
+
`Table` to render your own row shape is the system working. Reimplementing
|
|
64
|
+
`Table` is not.
|
|
65
|
+
|
|
66
|
+
## House rules
|
|
67
|
+
|
|
68
|
+
**Colour comes from tokens, never from a string literal.** Import `colors` and
|
|
69
|
+
use it. There are six tokens and only `accent` is a hex value — the rest are
|
|
70
|
+
named ANSI colours that adapt to the user's terminal theme:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { colors, spacing } from "@kud/ink-ui"
|
|
74
|
+
// colors.accent "#FF8C00" · muted · success · error · warning · info
|
|
75
|
+
// spacing.xs 1 · sm 2 · md 3 · lg 4
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A literal like `color="orange"` or `color="#FF8C00"` is wrong even when it
|
|
79
|
+
renders identically — it breaks the moment a token moves.
|
|
80
|
+
|
|
81
|
+
**State is never signalled by colour alone.** Every status carries a shape, a
|
|
82
|
+
glyph or a weight as well, because a colourblind reader cannot see the hue and
|
|
83
|
+
a piped terminal has no colour at all. `SelectableRow` marks the active row
|
|
84
|
+
with `❯` *and* bold, not just a tint. Hold that line in anything you add.
|
|
85
|
+
|
|
86
|
+
**Set the icon mode once, before the first render.** `setIconMode("nerd")`
|
|
87
|
+
swaps in Nerd Font glyphs; the default `"text"` is safe everywhere. Components
|
|
88
|
+
read it at render time, so calling it after mounting does nothing.
|
|
89
|
+
|
|
90
|
+
**There is no theme provider and no context.** Components take only the props
|
|
91
|
+
they need. Do not build a provider to pass tokens around — import them.
|
|
92
|
+
|
|
93
|
+
**ESM only.** `import`, never `require`. Node ≥ 20, with `ink` ≥ 7 and
|
|
94
|
+
`react` ≥ 19 as peer dependencies the consuming project installs itself.
|
|
95
|
+
|
|
96
|
+
## Traps
|
|
97
|
+
|
|
98
|
+
- **A row that overflows its container compresses every flexible child.** If a
|
|
99
|
+
gutter or marker column must hold its width, wrap it in `<Box flexShrink={0}>`.
|
|
100
|
+
This only bites on content long enough to overflow, so it survives short test
|
|
101
|
+
fixtures and breaks in real use.
|
|
102
|
+
- **`Table` needs `maxWidth`** when it sits inside a bordered `Panel`, or the
|
|
103
|
+
columns size against the terminal rather than the pane.
|
|
104
|
+
- **`Toast` returns `null` once it has expired** — it unmounts itself, so don't
|
|
105
|
+
rely on it holding layout space.
|
|
106
|
+
- **`useTabs` returns `active` as possibly `undefined`** when the item list is
|
|
107
|
+
empty. Guard before indexing.
|
|
108
|
+
|
|
109
|
+
## Working on this repo
|
|
110
|
+
|
|
111
|
+
If you are editing ink-ui itself rather than building with it: components stay
|
|
112
|
+
presentational unless they are in the uncontrolled list above, every new
|
|
113
|
+
component needs a `.test.tsx` beside it, and the public surface is whatever
|
|
114
|
+
`src/index.ts` exports — a component not exported there does not exist.
|
|
115
|
+
`npm run demo` renders the gallery. Run `npm run typecheck`, `npm test` and
|
|
116
|
+
`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
|
@@ -26,16 +26,25 @@ type SpinnerProps = {
|
|
|
26
26
|
};
|
|
27
27
|
declare const Spinner: ({ label }: SpinnerProps) => React__default.JSX.Element;
|
|
28
28
|
|
|
29
|
+
type ColumnAlign = "left" | "center" | "right";
|
|
30
|
+
type ColumnOverflow = "wrap" | "truncate";
|
|
31
|
+
|
|
29
32
|
type Column<T extends Record<string, unknown>> = {
|
|
30
33
|
key: keyof T & string;
|
|
31
34
|
header: string;
|
|
32
35
|
width?: number;
|
|
36
|
+
minWidth?: number;
|
|
37
|
+
align?: ColumnAlign;
|
|
38
|
+
overflow?: ColumnOverflow;
|
|
33
39
|
};
|
|
34
40
|
type TableProps<T extends Record<string, unknown>> = {
|
|
35
41
|
data: T[];
|
|
36
42
|
columns: Column<T>[];
|
|
43
|
+
gap?: number;
|
|
44
|
+
maxWidth?: number;
|
|
45
|
+
headerColor?: string;
|
|
37
46
|
};
|
|
38
|
-
declare const Table: <T extends Record<string, unknown>>({ data, columns, }: TableProps<T>) => React__default.JSX.Element;
|
|
47
|
+
declare const Table: <T extends Record<string, unknown>>({ data, columns, gap, maxWidth, headerColor, }: TableProps<T>) => React__default.JSX.Element;
|
|
39
48
|
|
|
40
49
|
type Hint = [key: string, label: string];
|
|
41
50
|
type FooterHintsProps = {
|
|
@@ -312,4 +321,4 @@ declare const spacing: {
|
|
|
312
321
|
};
|
|
313
322
|
type Spacing = (typeof spacing)[keyof typeof spacing];
|
|
314
323
|
|
|
315
|
-
export { Alert, Badge, type BadgeVariant, Banner, type Color, type Column, 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 };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Box, Text, useInput, measureElement } from 'ink';
|
|
1
|
+
import { Box, Text, useStdout, useInput, measureElement } from 'ink';
|
|
2
2
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
3
|
import React8, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect } from 'react';
|
|
4
4
|
import cliSpinners from 'cli-spinners';
|
|
5
|
+
import stringWidth from 'string-width';
|
|
5
6
|
import { glyphs } from '@kud/glyphs';
|
|
6
7
|
import { spawn } from 'child_process';
|
|
7
8
|
|
|
@@ -56,13 +57,100 @@ var Spinner = ({ label }) => {
|
|
|
56
57
|
label && /* @__PURE__ */ jsx(Text, { dimColor: true, children: label })
|
|
57
58
|
] });
|
|
58
59
|
};
|
|
60
|
+
var DEFAULT_MIN_WIDTH = 3;
|
|
61
|
+
var widestLine = (value) => value.split("\n").reduce((widest, line) => Math.max(widest, stringWidth(line)), 0);
|
|
62
|
+
var naturalWidth = (column, cells) => cells.reduce(
|
|
63
|
+
(widest, cell) => Math.max(widest, widestLine(cell)),
|
|
64
|
+
widestLine(column.header)
|
|
65
|
+
);
|
|
66
|
+
var floorFor = (column, width) => column.width !== void 0 ? width : Math.min(width, column.minWidth ?? DEFAULT_MIN_WIDTH);
|
|
67
|
+
var widestShrinkable = (widths, floors) => widths.reduce(
|
|
68
|
+
(best, width, index) => width > floors[index] && (best === -1 || width > widths[best]) ? index : best,
|
|
69
|
+
-1
|
|
70
|
+
);
|
|
71
|
+
var shrinkToFit = (widths, floors, excess) => {
|
|
72
|
+
const shrunk = [...widths];
|
|
73
|
+
let remaining = excess;
|
|
74
|
+
while (remaining > 0) {
|
|
75
|
+
const target = widestShrinkable(shrunk, floors);
|
|
76
|
+
if (target === -1) break;
|
|
77
|
+
shrunk[target] = shrunk[target] - 1;
|
|
78
|
+
remaining -= 1;
|
|
79
|
+
}
|
|
80
|
+
return shrunk;
|
|
81
|
+
};
|
|
82
|
+
var resolveColumnWidths = ({
|
|
83
|
+
columns,
|
|
84
|
+
rows,
|
|
85
|
+
gap,
|
|
86
|
+
maxWidth
|
|
87
|
+
}) => {
|
|
88
|
+
const widths = columns.map(
|
|
89
|
+
(column, index) => column.width ?? naturalWidth(
|
|
90
|
+
column,
|
|
91
|
+
rows.map((row) => row[index] ?? "")
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
const gaps = gap * Math.max(columns.length - 1, 0);
|
|
95
|
+
const excess = widths.reduce((total, width) => total + width, gaps) - maxWidth;
|
|
96
|
+
return excess > 0 ? shrinkToFit(
|
|
97
|
+
widths,
|
|
98
|
+
columns.map((column, index) => floorFor(column, widths[index])),
|
|
99
|
+
excess
|
|
100
|
+
) : widths;
|
|
101
|
+
};
|
|
102
|
+
var FALLBACK_TERMINAL_WIDTH = 80;
|
|
103
|
+
var justification = {
|
|
104
|
+
left: "flex-start",
|
|
105
|
+
center: "center",
|
|
106
|
+
right: "flex-end"
|
|
107
|
+
};
|
|
108
|
+
var cellText = (value) => String(value ?? "");
|
|
59
109
|
var Table = ({
|
|
60
110
|
data,
|
|
61
|
-
columns
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
columns,
|
|
112
|
+
gap = 2,
|
|
113
|
+
maxWidth,
|
|
114
|
+
headerColor = colors.muted
|
|
115
|
+
}) => {
|
|
116
|
+
const { stdout } = useStdout();
|
|
117
|
+
const rows = data.map(
|
|
118
|
+
(row) => columns.map((column) => cellText(row[column.key]))
|
|
119
|
+
);
|
|
120
|
+
const widths = resolveColumnWidths({
|
|
121
|
+
columns,
|
|
122
|
+
rows,
|
|
123
|
+
gap,
|
|
124
|
+
maxWidth: maxWidth ?? stdout?.columns ?? FALLBACK_TERMINAL_WIDTH
|
|
125
|
+
});
|
|
126
|
+
const cell = (column, index, content) => /* @__PURE__ */ jsx(
|
|
127
|
+
Box,
|
|
128
|
+
{
|
|
129
|
+
width: widths[index],
|
|
130
|
+
flexShrink: 0,
|
|
131
|
+
flexGrow: 0,
|
|
132
|
+
justifyContent: justification[column.align ?? "left"],
|
|
133
|
+
children: content
|
|
134
|
+
},
|
|
135
|
+
column.key
|
|
136
|
+
);
|
|
137
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
138
|
+
/* @__PURE__ */ jsx(Box, { gap, children: columns.map(
|
|
139
|
+
(column, index) => cell(
|
|
140
|
+
column,
|
|
141
|
+
index,
|
|
142
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: headerColor, wrap: "truncate-end", children: column.header })
|
|
143
|
+
)
|
|
144
|
+
) }),
|
|
145
|
+
rows.map((cells, rowIndex) => /* @__PURE__ */ jsx(Box, { gap, children: columns.map(
|
|
146
|
+
(column, index) => cell(
|
|
147
|
+
column,
|
|
148
|
+
index,
|
|
149
|
+
/* @__PURE__ */ jsx(Text, { wrap: column.overflow === "wrap" ? "wrap" : "truncate-end", children: cells[index] })
|
|
150
|
+
)
|
|
151
|
+
) }, rowIndex))
|
|
152
|
+
] });
|
|
153
|
+
};
|
|
66
154
|
var FooterHints = ({ hints }) => /* @__PURE__ */ jsx(Box, { columnGap: 2, rowGap: 0, flexWrap: "wrap", children: hints.map(([key, label]) => /* @__PURE__ */ jsxs(Box, { children: [
|
|
67
155
|
/* @__PURE__ */ jsx(Text, { color: "white", children: key }),
|
|
68
156
|
/* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + label })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kud/ink-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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
|
},
|
|
@@ -32,7 +34,8 @@
|
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@kud/glyphs": "0.1.1",
|
|
34
36
|
"cli-spinners": "3.4.0",
|
|
35
|
-
"figures": "6.1.0"
|
|
37
|
+
"figures": "6.1.0",
|
|
38
|
+
"string-width": "8.2.2"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@types/node": "22.20.1",
|