@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 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
- }) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
63
- /* @__PURE__ */ jsx(Box, { gap: 2, children: columns.map((col) => /* @__PURE__ */ jsx(Box, { width: col.width, minWidth: col.width, children: /* @__PURE__ */ jsx(Text, { bold: true, color: colors.muted, children: col.header }) }, col.key)) }),
64
- data.map((row, i) => /* @__PURE__ */ jsx(Box, { gap: 2, children: columns.map((col) => /* @__PURE__ */ jsx(Box, { width: col.width, minWidth: col.width, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: String(row[col.key] ?? "") }) }, col.key)) }, i))
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.14.1",
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",