@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 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.15.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
  },