diff --git a/.agents/skills/ink/SKILL.md b/.agents/skills/ink/SKILL.md new file mode 100644 index 0000000..308a7c5 --- /dev/null +++ b/.agents/skills/ink/SKILL.md @@ -0,0 +1,238 @@ +--- +name: ink +description: Comprehensive Ink skill for building CLI applications with React. Covers components (Text, Box, Static, Transform), hooks (useInput, useApp, useFocus), Flexbox layout, testing, and accessibility. +metadata: + references: core, components, hooks, layout, testing, accessibility +--- + +# Ink Platform Skill + +Consolidated skill for building CLI applications with Ink (React for CLIs). Use decision trees below to find the right components and patterns, then load detailed references. + +## Critical Rules + +**Follow these rules in all Ink code:** + +1. **All text must be wrapped in ``.** Raw strings outside `` will throw an error. +2. **`` only allows text nodes and nested ``.** You cannot nest `` inside ``. +3. **`` is always `display: flex`.** Think of every `` as `
`. +4. **Use `useApp().exit()` to exit.** Never call `process.exit()` directly from within components. +5. **Install both `ink` and `react`.** They are peer dependencies: `npm install ink react`. +6. **`` only renders new items.** Changes to previously rendered items are ignored. + +## How to Use This Skill + +### Reference File Structure + +Core references follow a 5-file pattern. Cross-cutting concepts are single-file guides. + +`./references/core/` contains: + +| File | Purpose | When to Read | +|------|---------|--------------| +| `REFERENCE.md` | Overview, when to use, quick start | **Always read first** | +| `api.md` | render(), renderToString(), Instance, measureElement | Writing code | +| `configuration.md` | Render options, environment vars | Configuring an app | +| `patterns.md` | Common patterns, best practices | Implementation guidance | +| `gotchas.md` | Pitfalls, limitations, debugging | Troubleshooting | + +Component, hook, and concept references in `./references//` have `REFERENCE.md` as the entry point. + +### Reading Order + +1. Start with `core/REFERENCE.md` for project overview +2. Then read additional files relevant to your task: + - Building UI -> `components/REFERENCE.md` + specific component files + - Handling input -> `hooks/input.md` + - Layout/positioning -> `layout/REFERENCE.md` + - App lifecycle -> `hooks/app-lifecycle.md` + - Focus management -> `hooks/focus.md` + - Testing -> `testing/REFERENCE.md` + - Accessibility -> `accessibility/REFERENCE.md` + - Troubleshooting -> `core/gotchas.md` + - **Best practices / rules** -> `rules/RULES.md` + specific rule files + +### Example Paths + +``` +./references/core/REFERENCE.md # Start here +./references/core/api.md # render(), renderToString() +./references/components/text.md # component +./references/components/box.md # component (layout, borders) +./references/hooks/input.md # useInput hook +./references/layout/patterns.md # Common layout recipes +./references/testing/REFERENCE.md # ink-testing-library +./rules/RULES.md # Best practices entry point +./rules/performance.md # FPS, Static, memoization +./rules/components.md # Per-component rules +./rules/hooks.md # Per-hook rules +./rules/core.md # render(), errors, environment +``` + +## Quick Decision Trees + +### "I need to display content" + +``` +Display content? +├─ Plain or styled text -> components/text.md +├─ Container with layout -> components/box.md +├─ Container with borders -> components/box.md (borderStyle) +├─ Container with background color -> components/box.md (backgroundColor) +├─ Line breaks within text -> components/utilities.md (Newline) +├─ Flexible spacer -> components/utilities.md (Spacer) +├─ Permanent log output (completed items) -> components/utilities.md (Static) +└─ Transform text output (uppercase, gradient) -> components/utilities.md (Transform) +``` + +### "I need user input" + +``` +User input? +├─ Keyboard shortcuts/arrow keys -> hooks/input.md (useInput) +├─ Raw stdin access -> hooks/stdio.md (useStdin) +├─ Tab/Shift+Tab focus cycling -> hooks/focus.md (useFocus) +├─ Programmatic focus control -> hooks/focus.md (useFocusManager) +└─ Cursor positioning (IME) -> hooks/focus.md (useCursor) +``` + +### "I need layout/positioning" + +``` +Layout? +├─ Horizontal row of elements -> layout/REFERENCE.md (flexDirection: row) +├─ Vertical stack of elements -> layout/REFERENCE.md (flexDirection: column) +├─ Centering content -> layout/patterns.md +├─ Spacing between elements -> layout/REFERENCE.md (gap, margin, padding) +├─ Fixed width/height -> components/box.md (width, height) +├─ Percentage sizing -> components/box.md (width: "50%") +├─ Min/max constraints -> components/box.md (minWidth, maxWidth, maxHeight) +├─ Push elements apart -> components/utilities.md (Spacer) +├─ Flex grow/shrink -> layout/REFERENCE.md (flexGrow, flexShrink) +├─ Wrapping content -> layout/REFERENCE.md (flexWrap) +├─ Overflow control -> components/box.md (overflow) +└─ Complex nested layouts -> layout/patterns.md +``` + +### "I need to manage app lifecycle" + +``` +App lifecycle? +├─ Mount and render -> core/api.md (render) +├─ Render to string (no terminal) -> core/api.md (renderToString) +├─ Exit the app programmatically -> hooks/app-lifecycle.md (useApp, exit) +├─ Wait for app to finish -> core/api.md (waitUntilExit) +├─ Re-render with new props -> core/api.md (rerender) +├─ Unmount the app -> core/api.md (unmount) +└─ Write to stdout/stderr outside Ink -> hooks/stdio.md +``` + +### "I need to test my CLI" + +``` +Testing? +├─ Render and check output -> testing/REFERENCE.md +├─ Simulate user input -> testing/REFERENCE.md (stdin.write) +├─ Snapshot testing -> testing/REFERENCE.md +└─ Check last rendered frame -> testing/REFERENCE.md (lastFrame) +``` + +### "I need accessibility" + +``` +Accessibility? +├─ Screen reader support -> accessibility/REFERENCE.md +├─ ARIA roles (checkbox, button, etc.) -> accessibility/REFERENCE.md +├─ ARIA state (checked, disabled, etc.) -> accessibility/REFERENCE.md +├─ Custom screen reader labels -> accessibility/REFERENCE.md (aria-label) +└─ Hide from screen readers -> accessibility/REFERENCE.md (aria-hidden) +``` + +### "I need to debug/troubleshoot" + +``` +Troubleshooting? +├─ Text rendering issues -> core/gotchas.md +├─ Layout problems -> core/gotchas.md + layout/REFERENCE.md +├─ Input not working -> core/gotchas.md + hooks/input.md +├─ Process not exiting -> core/gotchas.md +├─ CI rendering issues -> core/configuration.md (CI mode) +├─ Console output mixing -> core/configuration.md (patchConsole) +└─ Performance/flickering -> core/configuration.md + rules/performance.md +``` + +### "I want best practices / production-ready code" + +``` +Best practices? +├─ General rules (critical) -> rules/RULES.md +├─ Performance (FPS, Static, memoization) -> rules/performance.md +├─ Per-component patterns & anti-patterns -> rules/components.md +├─ Per-hook patterns & gotchas -> rules/hooks.md +└─ render() / errors / environment behavior -> rules/core.md +``` + +### Troubleshooting Index + +- Text outside `` -> `core/gotchas.md` +- `` inside `` -> `core/gotchas.md` +- Process hanging/not exiting -> `core/gotchas.md` +- Console.log mixing with output -> `core/configuration.md` +- Layout misalignment -> `layout/REFERENCE.md` +- Input not received -> `hooks/input.md` + `rules/hooks.md` +- Focus not cycling -> `hooks/focus.md` + `rules/hooks.md` +- CI output issues -> `core/configuration.md` + `rules/core.md` +- Flickering/performance -> `core/configuration.md` + `rules/performance.md` +- Anti-patterns / pitfalls -> `rules/components.md`, `rules/hooks.md`, `rules/core.md` + +## Product Index + +### Core +| Area | Entry File | Description | +|------|------------|-------------| +| Core | `./references/core/REFERENCE.md` | Overview, quick start, project setup | +| API | `./references/core/api.md` | render, renderToString, Instance | +| Config | `./references/core/configuration.md` | Render options, environment variables | +| Patterns | `./references/core/patterns.md` | Common patterns and recipes | +| Gotchas | `./references/core/gotchas.md` | Pitfalls and debugging | + +### Components +| Component | Entry File | Description | +|-----------|------------|-------------| +| Overview | `./references/components/REFERENCE.md` | All components at a glance | +| Text | `./references/components/text.md` | Text display and styling | +| Box | `./references/components/box.md` | Layout, borders, backgrounds | +| Utilities | `./references/components/utilities.md` | Newline, Spacer, Static, Transform | + +### Hooks +| Hook | Entry File | Description | +|------|------------|-------------| +| Overview | `./references/hooks/REFERENCE.md` | All hooks at a glance | +| Input | `./references/hooks/input.md` | useInput for keyboard handling | +| App Lifecycle | `./references/hooks/app-lifecycle.md` | useApp for exit control | +| Stdio | `./references/hooks/stdio.md` | useStdin, useStdout, useStderr | +| Focus | `./references/hooks/focus.md` | useFocus, useFocusManager, useCursor | + +### Cross-Cutting Concepts +| Concept | Entry File | Description | +|---------|------------|-------------| +| Layout | `./references/layout/REFERENCE.md` | Yoga/Flexbox layout system | +| Layout Patterns | `./references/layout/patterns.md` | Common layout recipes | +| Testing | `./references/testing/REFERENCE.md` | ink-testing-library | +| Accessibility | `./references/accessibility/REFERENCE.md` | Screen reader & ARIA support | + +### Best Practices (Rules) +| Rule File | Entry File | Description | +|-----------|------------|-------------| +| Overview | `./rules/RULES.md` | Entry point + 10 critical rules | +| Performance | `./rules/performance.md` | FPS tuning, Static, memoization, incremental rendering | +| Components | `./rules/components.md` | Box, Text, Static, Transform, Newline, Spacer rules | +| Hooks | `./rules/hooks.md` | useInput, useApp, useFocus, useCursor, useStdin rules | +| Core | `./rules/core.md` | render(), renderToString(), errors, CI, Kitty protocol | + +## Resources + +**Repository**: https://github.com/vadimdemedes/ink +**npm**: https://www.npmjs.com/package/ink +**Testing Library**: https://github.com/vadimdemedes/ink-testing-library +**Create Ink App**: https://github.com/vadimdemedes/create-ink-app diff --git a/.agents/skills/ink/references/accessibility/REFERENCE.md b/.agents/skills/ink/references/accessibility/REFERENCE.md new file mode 100644 index 0000000..991dcdf --- /dev/null +++ b/.agents/skills/ink/references/accessibility/REFERENCE.md @@ -0,0 +1,255 @@ +# Accessibility + +Ink supports screen readers via the Ink Screen Reader project. When enabled, Ink renders output compatible with assistive technologies. + +## Enabling Screen Reader Support + +### Environment Variable + +```bash +INK_SCREEN_READER=true my-cli-app +``` + +### Render Option + +```jsx +render(, {isScreenReaderEnabled: true}); +``` + +### Check From Components + +```jsx +import {useIsScreenReaderEnabled} from 'ink'; + +const App = () => { + const isEnabled = useIsScreenReaderEnabled(); + // Adapt UI for screen readers if needed +}; +``` + +## ARIA Roles + +Announce the purpose of a `` to screen readers: + +```jsx + + 3 tasks remaining + + + + Error: Connection failed + +``` + +### Supported Roles + +| Role | Description | +|------|-------------| +| `status` | Live region with polite updates | +| `alert` | Live region with assertive (immediate) updates | +| `timer` | Ticking timer | +| `progressbar` | Progress indication | +| `img` | Image (use with `aria-label` for description) | +| `checkbox` | Toggle control | +| `radiogroup` | Group of radio buttons | +| `radiobutton` | Single radio option | +| `button` | Clickable action | +| `textbox` | Text input | +| `listbox` | List of options | +| `option` | Option within a listbox | +| `none` | Remove semantic meaning | + +## ARIA Properties + +### aria-label + +Human-readable label, overrides visible text: + +```jsx + + {'🏢'} + + + + [Submit] + +``` + +### aria-live + +How updates are announced: + +```jsx + + {/* Announced when screen reader is idle */} + {statusMessage} + + + + {/* Announced immediately, interrupting */} + {errorMessage} + + + + {/* Not announced */} + +``` + +### aria-checked + +For checkbox/radio components: + +```jsx + + {isChecked ? '[x]' : '[ ]'} Accept terms + +``` + +Supported values: `true`, `false`, `'mixed'` + +### aria-selected + +For listbox options: + +```jsx + + {items.map(item => ( + + {item.label} + + ))} + +``` + +### aria-disabled + +Mark elements as non-interactive: + +```jsx + + [Disabled] + +``` + +### aria-expanded + +For expandable/collapsible sections: + +```jsx + + {isOpen ? '▼' : '▶'} Details + +``` + +### aria-valuemin / aria-valuemax / aria-valuenow / aria-valuetext + +For progress bars: + +```jsx + + {'█'.repeat(progress / 5)}{'░'.repeat(20 - progress / 5)} + +``` + +### aria-hidden + +Hide from screen readers: + +```jsx + + Decorative border ═══════ + +``` + +## Patterns + +### Accessible Checkbox List + +```jsx +const CheckboxList = ({items, selected, onToggle}) => ( + + {items.map(item => ( + + + {selected.has(item.id) ? '[x]' : '[ ]'} {item.label} + + + ))} + +); +``` + +### Accessible Select Menu + +```jsx +const SelectMenu = ({items, selectedIndex}) => ( + + {items.map((item, i) => ( + + + {i === selectedIndex ? '> ' : ' '}{item} + + + ))} + +); +``` + +### Accessible Progress Bar + +```jsx +const AccessibleProgress = ({label, percent}) => ( + + {label}: + {'█'.repeat(Math.round(20 * percent))} + {'░'.repeat(20 - Math.round(20 * percent))} + {Math.round(percent * 100)}% + +); +``` + +### Status Announcements + +```jsx +const StatusBar = ({message, type}) => ( + + + {type === 'error' ? '✖' : '✔'} {message} + + +); +``` + +## Best Practices + +1. **Use semantic roles** — helps screen readers understand UI structure +2. **Provide `aria-label`** for decorative or icon-only elements +3. **Use `aria-live`** for dynamic content that updates +4. **Use `aria-hidden`** for decorative elements (borders, separators) +5. **Test with screen reader mode** — set `INK_SCREEN_READER=true` during development + +## See Also + +- [Focus](../hooks/focus.md) - useFocus, useFocusManager +- [Configuration](../core/configuration.md) - isScreenReaderEnabled option +- [Box](../components/box.md) - Box component (accepts ARIA props) diff --git a/.agents/skills/ink/references/components/REFERENCE.md b/.agents/skills/ink/references/components/REFERENCE.md new file mode 100644 index 0000000..f637a18 --- /dev/null +++ b/.agents/skills/ink/references/components/REFERENCE.md @@ -0,0 +1,47 @@ +# Ink Components + +Reference for all built-in Ink components. + +## Component Overview + +| Component | Purpose | File | +|-----------|---------|------| +| `` | Display and style text | [text.md](./text.md) | +| `` | Layout container (Flexbox), borders, backgrounds | [box.md](./box.md) | +| `` | Insert line breaks | [utilities.md](./utilities.md) | +| `` | Flexible space filler | [utilities.md](./utilities.md) | +| `` | Permanently rendered output | [utilities.md](./utilities.md) | +| `` | Transform string output | [utilities.md](./utilities.md) | + +## Component Chooser + +``` +Need a component? +├─ Styled text (color, bold, italic, etc.) -> text.md +├─ Layout container with flexbox -> box.md +├─ Borders around content -> box.md (borderStyle) +├─ Background color -> box.md (backgroundColor) +├─ Line break in text -> utilities.md (Newline) +├─ Push elements apart -> utilities.md (Spacer) +├─ Log/permanent output -> utilities.md (Static) +└─ Text transform (uppercase, gradient) -> utilities.md (Transform) +``` + +## Quick Import + +```jsx +import {Text, Box, Newline, Spacer, Static, Transform} from 'ink'; +``` + +## Key Rules + +1. **All text must be in ``** — raw strings in `` throw errors +2. **`` only contains text and nested ``** — no `` inside `` +3. **`` is always flexbox** — every `` is `display: flex` +4. **`` only renders new items** — previously rendered items don't update + +## See Also + +- [Layout](../layout/REFERENCE.md) - Flexbox layout system +- [Hooks](../hooks/REFERENCE.md) - All hooks reference +- [Core API](../core/api.md) - render(), renderToString() diff --git a/.agents/skills/ink/references/components/box.md b/.agents/skills/ink/references/components/box.md new file mode 100644 index 0000000..5ae507e --- /dev/null +++ b/.agents/skills/ink/references/components/box.md @@ -0,0 +1,252 @@ +# `` Component + +Essential layout container. Works like `
` in the browser. + +## Import + +```jsx +import {Box} from 'ink'; +``` + +## Basic Usage + +```jsx + + Content with margin + +``` + +## Dimensions + +### width / height + +Type: `number | string` + +Fixed size in spaces (columns/rows), or percentage of parent: + +```jsx +... +... +``` + +### minWidth / minHeight + +Type: `number` (minWidth) | `number | string` (minHeight) + +```jsx +... +... +``` + +**Note:** `minWidth` does not support percentages (Yoga limitation). + +### maxWidth / maxHeight + +Type: `number` (maxWidth) | `number | string` (maxHeight) + +```jsx +... +... +``` + +**Note:** `maxWidth` does not support percentages (Yoga limitation). + +## Padding + +All padding props: Type `number`, Default `0`. + +```jsx +... // All sides +... // Left + right +... // Top + bottom +... // Individual +... +``` + +## Margin + +All margin props: Type `number`, Default `0`. + +```jsx +... // All sides +... // Left + right +... // Top + bottom +... // Individual +``` + +## Gap + +### gap + +Type: `number` | Default: `0` + +Space between children (shorthand for `columnGap` and `rowGap`): + +```jsx + + A + B + C + +// A B +// +// C +``` + +### columnGap / rowGap + +Type: `number` | Default: `0` + +```jsx + + A + B + +// A B +``` + +## Flex + +### flexDirection + +Type: `string` | Allowed: `'row'` `'row-reverse'` `'column'` `'column-reverse'` + +```jsx + + Top + Bottom + +``` + +### flexGrow / flexShrink / flexBasis + +```jsx + + Label: + Fills remaining space + +``` + +### flexWrap + +Type: `string` | Allowed: `'nowrap'` `'wrap'` `'wrap-reverse'` + +### alignItems + +Type: `string` | Allowed: `'flex-start'` `'center'` `'flex-end'` + +### alignSelf + +Type: `string` | Default: `'auto'` | Allowed: `'auto'` `'flex-start'` `'center'` `'flex-end'` + +### justifyContent + +Type: `string` | Allowed: `'flex-start'` `'center'` `'flex-end'` `'space-between'` `'space-around'` `'space-evenly'` + +See [Layout Reference](../layout/REFERENCE.md) for detailed flex examples. + +## Display + +### display + +Type: `string` | Allowed: `'flex'` `'none'` | Default: `'flex'` + +Hide elements with `display="none"`. + +## Overflow + +### overflow / overflowX / overflowY + +Type: `string` | Allowed: `'visible'` `'hidden'` | Default: `'visible'` + +```jsx + + Content that might be too long + +``` + +## Borders + +### borderStyle + +Type: `string | BoxStyle` | Allowed: `'single'` `'double'` `'round'` `'bold'` `'singleDouble'` `'doubleSingle'` `'classic'` + +```jsx + + Rounded box + +``` + +Custom border style: + +```jsx + + Custom + +``` + +### borderColor + +Type: `string` + +Shorthand for all border sides: + +```jsx + + Green border + +``` + +Individual: `borderTopColor`, `borderRightColor`, `borderBottomColor`, `borderLeftColor` + +### borderDimColor + +Type: `boolean` | Default: `false` + +Dim the border color. Individual: `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor`, `borderRightDimColor` + +### Border Visibility + +`borderTop`, `borderRight`, `borderBottom`, `borderLeft`: Type `boolean`, Default `true` + +Show/hide individual border sides: + +```jsx + + No bottom border + +``` + +## Background + +### backgroundColor + +Type: `string` + +Background color fills the entire `` area. Inherited by child `` unless they override: + +```jsx + + Blue inherited + Yellow override + Blue inherited again + +``` + +Works with borders and padding: + +```jsx + + Background with border and padding + +``` + +## See Also + +- [Text](./text.md) - Text display component +- [Layout](../layout/REFERENCE.md) - Flexbox layout system +- [Layout Patterns](../layout/patterns.md) - Common layout recipes diff --git a/.agents/skills/ink/references/components/text.md b/.agents/skills/ink/references/components/text.md new file mode 100644 index 0000000..98e5a83 --- /dev/null +++ b/.agents/skills/ink/references/components/text.md @@ -0,0 +1,146 @@ +# `` Component + +Display text and change its style. Supports color, bold, italic, underline, strikethrough, inverse, and dim. + +## Import + +```jsx +import {Text} from 'ink'; +``` + +## Basic Usage + +```jsx +Hello World +Green text +Bold text +``` + +## Props + +### color + +Type: `string` + +Change text color. Uses [chalk](https://github.com/chalk/chalk) under the hood. + +```jsx +Green +Blue +Red +``` + +### backgroundColor + +Type: `string` + +Background color for text. Same format as `color`. + +```jsx +Green bg +Blue bg +``` + +### dimColor + +Type: `boolean` | Default: `false` + +Make the color less bright: + +```jsx +Dimmed Red +``` + +### bold + +Type: `boolean` | Default: `false` + +```jsx +Bold text +``` + +### italic + +Type: `boolean` | Default: `false` + +```jsx +Italic text +``` + +### underline + +Type: `boolean` | Default: `false` + +```jsx +Underlined text +``` + +### strikethrough + +Type: `boolean` | Default: `false` + +```jsx +Crossed out +``` + +### inverse + +Type: `boolean` | Default: `false` + +Swap foreground and background colors: + +```jsx +Inversed Yellow +``` + +### wrap + +Type: `string` | Default: `'wrap'` +Allowed: `'wrap'` `'truncate'` `'truncate-start'` `'truncate-middle'` `'truncate-end'` + +Controls text wrapping/truncation when wider than container: + +```jsx + + Hello World + +// 'Hello\nWorld' + + + Hello World + +// 'Hello…' + + + Hello World + +// 'He…ld' + + + Hello World + +// '…World' +``` + +**Note:** `truncate` is an alias for `truncate-end`. + +## Nesting + +`` can contain nested `` to compose styles: + +```jsx + + Hello bold and green + +``` + +## Restrictions + +- `` can only contain text nodes and other `` components +- `` cannot be placed inside `` +- All visible text must be wrapped in `` — raw strings in `` throw errors + +## See Also + +- [Box](./box.md) - Container component +- [Utilities](./utilities.md) - Newline, Transform diff --git a/.agents/skills/ink/references/components/utilities.md b/.agents/skills/ink/references/components/utilities.md new file mode 100644 index 0000000..1964709 --- /dev/null +++ b/.agents/skills/ink/references/components/utilities.md @@ -0,0 +1,184 @@ +# Utility Components + +## `` + +Adds newline characters. Must be used within ``. + +```jsx +import {Text, Newline} from 'ink'; + + + Hello + + World + +``` + +### Props + +#### count + +Type: `number` | Default: `1` + +Number of newlines to insert: + +```jsx + + Line 1 + + Line 2 + +``` + +## `` + +Flexible space that expands along the major axis. Shortcut for filling available space. + +```jsx +import {Box, Text, Spacer} from 'ink'; + +// Push items apart horizontally + + Left + + Right + + +// Push items apart vertically + + Top + + Bottom + +``` + +`` is equivalent to ``. + +## `` + +Permanently render output above everything else. Useful for completed tasks, logs — things that don't change after rendering. + +```jsx +import {render, Static, Box, Text} from 'ink'; +``` + +### Usage + +```jsx +const App = () => { + const [tests, setTests] = useState([]); + + return ( + <> + {/* Rendered once, permanently */} + + {test => ( + + ✔ {test.title} + + )} + + + {/* Keeps updating */} + + Completed: {tests.length} + + + ); +}; +``` + +### Props + +#### items + +Type: `Array` + +Array of items to render. + +#### style + +Type: `object` + +Styles for the container. Accepts same properties as ``: + +```jsx + + {...} + +``` + +#### children(item, index) + +Type: `Function` + +Render function called for each item. Must return element with a `key`: + +```jsx + + {(item, index) => ( + + Item: {item} + + )} + +``` + +### Important Notes + +- `` **only renders new items**. Changes to previously rendered items are ignored. +- When adding items, only the new ones get rendered. +- Best for logs, completed tasks, or any output that shouldn't change. + +## `` + +Transform the string representation of child components before output. + +```jsx +import {Transform, Text} from 'ink'; + +// Uppercase everything + output.toUpperCase()}> + Hello World + +// Output: HELLO WORLD +``` + +### Props + +#### transform(outputLine, index) + +Type: `Function` + +Receives each line of output and its zero-indexed line number: + +```jsx +// Hanging indent: indent all lines except first +const HangingIndent = ({indent = 4, children}) => ( + + index === 0 ? line : ' '.repeat(indent) + line + } + > + {children} + +); +``` + +### Restrictions + +- Must only wrap `` children +- Should not change output dimensions (width/height), or layout will be incorrect +- Use for visual transforms: gradients, links, text effects + +### Use Cases + +- Text gradients (with [ink-gradient](https://github.com/sindresorhus/ink-gradient)) +- Clickable links (with [ink-link](https://github.com/sindresorhus/ink-link)) +- ASCII art text (with [ink-big-text](https://github.com/sindresorhus/ink-big-text)) + +## See Also + +- [Text](./text.md) - Text component +- [Box](./box.md) - Layout container +- [Components Overview](./REFERENCE.md) - All components diff --git a/.agents/skills/ink/references/core/REFERENCE.md b/.agents/skills/ink/references/core/REFERENCE.md new file mode 100644 index 0000000..05d3a2c --- /dev/null +++ b/.agents/skills/ink/references/core/REFERENCE.md @@ -0,0 +1,109 @@ +# Ink (React for CLIs) + +Build and test CLI output using React components. Ink provides the same component-based UI building experience that React offers in the browser, but for command-line apps. + +## Overview + +Ink uses [Yoga](https://github.com/facebook/yoga) to build Flexbox layouts in the terminal: +- **Components**: ``, ``, ``, ``, ``, `` +- **Hooks**: `useInput`, `useApp`, `useFocus`, `useFocusManager`, `useStdin`, `useStdout`, `useStderr`, `useCursor` +- **Layout**: Full Flexbox via Yoga (every `` is `display: flex`) +- **Testing**: `ink-testing-library` for headless testing + +## When to Use Ink + +Use Ink when: +- Building interactive CLI tools +- Need component-based UI architecture for terminal apps +- Want familiar React patterns (hooks, state, effects) in the CLI +- Building complex CLI UIs with layouts, borders, colors +- Need testable terminal UI components + +## Quick Start + +### Using create-ink-app (Recommended) + +```bash +npx create-ink-app my-ink-cli +cd my-ink-cli +``` + +For TypeScript: + +```bash +npx create-ink-app --typescript my-ink-cli +``` + +### Manual Setup + +```bash +mkdir my-cli && cd my-cli +npm init -y +npm install ink react +``` + +```jsx +import React, {useState, useEffect} from 'react'; +import {render, Text} from 'ink'; + +const Counter = () => { + const [counter, setCounter] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCounter(prev => prev + 1); + }, 100); + + return () => clearInterval(timer); + }, []); + + return {counter} tests passed; +}; + +render(); +``` + +## Core Concepts + +### Everything is Flexbox + +Every `` in Ink behaves like `
`. There is no block or inline layout — it's Flexbox all the way down. + +### Text Must Be Wrapped + +All text must be inside a `` component. Raw strings outside `` will throw an error. + +```jsx +// CORRECT +Hello + +// WRONG - will throw +Hello +``` + +### App Lifecycle + +An Ink app is a Node.js process. It stays alive only while there is active work in the event loop (timers, pending promises, `useInput` listening on stdin). If your component tree has no async work, the app renders once and exits immediately. + +## Essential Commands + +```bash +npm install ink react # Install +node my-app.js # Run (with Babel/TypeScript) +npx create-ink-app my-cli # Scaffold new project +``` + +## In This Reference + +- [API](./api.md) - render(), renderToString(), Instance, measureElement() +- [Configuration](./configuration.md) - Render options, environment variables, CI mode +- [Patterns](./patterns.md) - Common patterns, recipes, best practices +- [Gotchas](./gotchas.md) - Pitfalls, limitations, debugging tips + +## See Also + +- [Components](../components/REFERENCE.md) - Text, Box, Static, Transform +- [Hooks](../hooks/REFERENCE.md) - useInput, useApp, useFocus, etc. +- [Layout](../layout/REFERENCE.md) - Yoga/Flexbox layout system +- [Testing](../testing/REFERENCE.md) - ink-testing-library +- [Accessibility](../accessibility/REFERENCE.md) - Screen reader & ARIA support diff --git a/.agents/skills/ink/references/core/api.md b/.agents/skills/ink/references/core/api.md new file mode 100644 index 0000000..77b148f --- /dev/null +++ b/.agents/skills/ink/references/core/api.md @@ -0,0 +1,138 @@ +# Core API Reference + +## render(tree, options?) + +Mount a component and render the output. Returns an `Instance`. + +```jsx +import {render} from 'ink'; + +const instance = render(); +``` + +### Options + +```jsx +render(, { + stdout: process.stdout, // Output stream (default: process.stdout) + stdin: process.stdin, // Input stream (default: process.stdin) + stderr: process.stderr, // Error stream (default: process.stderr) + exitOnCtrlC: true, // Exit on Ctrl+C (default: true) + patchConsole: true, // Patch console.* methods (default: true) + debug: false, // Render each update separately (default: false) + maxFps: 30, // Max frames per second (default: 30) + incrementalRendering: false, // Only update changed lines (default: false) + concurrent: false, // React Concurrent mode (default: false) + isScreenReaderEnabled: false, // Screen reader support (default: env check) + onRender: ({renderTime}) => {},// Callback after each render + kittyKeyboard: undefined, // Kitty keyboard protocol options +}); +``` + +### Instance + +The object returned by `render()`: + +```jsx +const {rerender, unmount, waitUntilExit, cleanup, clear} = render(); +``` + +#### rerender(tree) + +Replace or update the root node: + +```jsx +const {rerender} = render(); +rerender(); +``` + +#### unmount() + +Manually unmount the whole app: + +```jsx +const {unmount} = render(); +unmount(); +``` + +#### waitUntilExit() + +Returns a promise that settles when the app unmounts: + +```jsx +const {waitUntilExit} = render(); +await waitUntilExit(); // resolves after unmount +``` + +It resolves with the value passed to `exit(value)` and rejects with the error passed to `exit(error)`. + +#### cleanup() + +Delete the internal Ink instance for the current stdout. Useful in tests where you need `render()` to create a fresh instance. + +#### clear() + +Clear rendered output: + +```jsx +const {clear} = render(); +clear(); +``` + +## renderToString(tree, options?) + +Render a React element to a string synchronously. Does not write to stdout, does not set up terminal listeners. + +```jsx +import {renderToString, Text, Box} from 'ink'; + +const output = renderToString( + + Hello World + , +); + +console.log(output); +``` + +### Options + +```jsx +renderToString(, { + columns: 80, // Virtual terminal width (default: 80) +}); +``` + +**Notes:** +- Terminal hooks (`useInput`, `useStdin`, `useApp`, etc.) return default no-op values +- `useEffect` callbacks execute but state updates don't affect the returned output +- `useLayoutEffect` callbacks fire synchronously and **will** affect the output +- `` is supported — its output is prepended + +## measureElement(ref) + +Measure the dimensions of a `` element. Returns `{width, height}`. + +**Important:** Returns `{width: 0, height: 0}` during render. Call from `useEffect`, `useLayoutEffect`, input handlers, or timer callbacks. + +```jsx +import {useRef, useEffect} from 'react'; +import {render, measureElement, Box, Text} from 'ink'; + +const Example = () => { + const ref = useRef(); + + useEffect(() => { + const {width, height} = measureElement(ref.current); + // width = 100, height = 1 + }, []); + + return ( + + + This box will stretch to 100 width + + + ); +}; +``` diff --git a/.agents/skills/ink/references/core/configuration.md b/.agents/skills/ink/references/core/configuration.md new file mode 100644 index 0000000..43e3e0e --- /dev/null +++ b/.agents/skills/ink/references/core/configuration.md @@ -0,0 +1,180 @@ +# Configuration + +## Render Options + +### stdout / stdin / stderr + +Custom streams for output, input, and errors: + +```jsx +render(, { + stdout: customWriteStream, + stdin: customReadStream, + stderr: customErrorStream, +}); +``` + +### exitOnCtrlC + +Type: `boolean` | Default: `true` + +Listen for Ctrl+C and exit. Needed when stdin is in raw mode (Ctrl+C is ignored by default in raw mode). + +### patchConsole + +Type: `boolean` | Default: `true` + +Patch `console.*` methods so output doesn't mix with Ink's output. When enabled, Ink intercepts console calls, clears main output, renders console output, then rerenders the main output. + +### debug + +Type: `boolean` | Default: `false` + +Render each update as separate output instead of replacing previous output. Useful for debugging render cycles. + +### maxFps + +Type: `number` | Default: `30` + +Maximum frames per second. Controls how frequently the UI can update. Lower values reduce CPU usage for frequently-updating components. + +```jsx +render(, {maxFps: 10}); // Throttle to 10fps +``` + +### incrementalRendering + +Type: `boolean` | Default: `false` + +Only update changed lines instead of redrawing the entire output. Reduces flickering and improves performance for frequently updating UIs. + +```jsx +render(, {incrementalRendering: true}); +``` + +### concurrent + +Type: `boolean` | Default: `false` + +Enable React Concurrent Rendering mode: +- Suspense boundaries work with async data fetching +- `useTransition` and `useDeferredValue` hooks are functional +- Updates can be interrupted for higher priority work + +```jsx +render(, {concurrent: true}); +``` + +**Note:** The `concurrent` option only takes effect on the first render for a given stdout. Call `unmount()` first to change rendering mode. + +### onRender + +Type: `({renderTime: number}) => void` + +Callback after each render with metrics: + +```jsx +render(, { + onRender: ({renderTime}) => { + console.log(`Rendered in ${renderTime}ms`); + }, +}); +``` + +### isScreenReaderEnabled + +Type: `boolean` | Default: `process.env['INK_SCREEN_READER'] === 'true'` + +Enable screen reader support. See [Accessibility](../accessibility/REFERENCE.md). + +### kittyKeyboard + +Type: `object` | Default: `undefined` + +Enable the kitty keyboard protocol for enhanced keyboard input. + +```jsx +render(, {kittyKeyboard: {mode: 'auto'}}); +``` + +#### kittyKeyboard.mode + +- `'auto'`: Detect terminal support with heuristic precheck +- `'enabled'`: Force enable (stdin and stdout must be TTYs) +- `'disabled'`: Never enable + +#### kittyKeyboard.flags + +Default: `['disambiguateEscapeCodes']` + +Available flags: +- `'disambiguateEscapeCodes'` +- `'reportEventTypes'` +- `'reportAlternateKeys'` +- `'reportAllKeysAsEscapeCodes'` +- `'reportAssociatedText'` + +## CI Mode + +When running on CI (detected via `CI` environment variable): +- Only the last frame is rendered on exit +- Terminal resize events are not listened to + +Opt out with `CI=false`: + +```bash +CI=false node my-cli.js +``` + +## Environment Variables + +| Variable | Effect | +|----------|--------| +| `CI` | Enables CI rendering mode | +| `INK_SCREEN_READER` | Set to `'true'` to enable screen reader support | +| `DEV` | Enables React DevTools integration | + +## React DevTools + +Enable with `DEV=true`: + +```bash +DEV=true my-cli +npx react-devtools # In another terminal +``` + +You can inspect and change props live. + +## Project Setup + +### package.json (TypeScript) + +```json +{ + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "ink": "latest", + "react": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "typescript": "latest" + } +} +``` + +### Babel Setup (JavaScript) + +```bash +npm install --save-dev @babel/preset-react +``` + +```json +{ + "presets": ["@babel/preset-react"] +} +``` diff --git a/.agents/skills/ink/references/core/gotchas.md b/.agents/skills/ink/references/core/gotchas.md new file mode 100644 index 0000000..958e645 --- /dev/null +++ b/.agents/skills/ink/references/core/gotchas.md @@ -0,0 +1,180 @@ +# Gotchas + +## Text Outside `` + +All text must be wrapped in ``. Raw strings in `` will throw: + +```jsx +// WRONG - throws error +Hello World + +// CORRECT +Hello World +``` + +## `` Inside `` + +`` only allows text nodes and nested ``. No `` inside ``: + +```jsx +// WRONG - throws error +Hello World + +// CORRECT +Hello World +``` + +## Process Not Exiting + +An Ink app stays alive while there's async work in the event loop. If your app renders once and should exit, make sure there are no lingering timers or listeners. + +```jsx +// Problem: interval keeps process alive forever +useEffect(() => { + setInterval(() => {}, 1000); +}, []); + +// Fix: clean up timers +useEffect(() => { + const timer = setInterval(() => {}, 1000); + return () => clearInterval(timer); +}, []); +``` + +To exit programmatically, use `useApp().exit()`: + +```jsx +const {exit} = useApp(); +exit(); // unmounts and exits +``` + +## DEV Environment Variable Hanging + +If `DEV` is set but React DevTools server is not running, the process will hang trying to connect. Only set `DEV=true` when you have `npx react-devtools` running. + +## Console Output Mixing + +Without `patchConsole: true` (default), `console.log()` output would overwrite Ink's rendered output. Ink patches console methods to render them above the main output. + +If you need to disable this: +```jsx +render(, {patchConsole: false}); +``` + +## CI Rendering + +On CI (detected via `CI` env var), Ink only renders the last frame on exit. This is because most CI environments don't support ANSI escape sequences for overwriting output. + +Override with `CI=false` if your CI supports full terminal rendering. + +## Flickering + +If the UI flickers, try: + +1. **Incremental rendering**: Only update changed lines + ```jsx + render(, {incrementalRendering: true}); + ``` + +2. **Lower maxFps**: Reduce update frequency + ```jsx + render(, {maxFps: 10}); + ``` + +3. **Avoid unnecessary re-renders**: Use `React.memo`, `useMemo`, `useCallback`. + +## useInput Not Receiving Input + +`useInput` requires stdin to be in raw mode. Ink handles this automatically when `useInput` is active. But if you have multiple `useInput` hooks, use the `isActive` option to control which one is active: + +```jsx +useInput(handler, {isActive: isFocused}); +``` + +## `` Only Renders New Items + +`` renders items once and ignores changes to previously rendered items. If you update an item in the array, the previously rendered version stays: + +```jsx +// Adding new items works +setItems(prev => [...prev, newItem]); + +// Modifying existing items does NOT re-render them in Static +setItems(prev => prev.map(item => + item.id === id ? {...item, status: 'done'} : item +)); +``` + +## measureElement Returns Zero + +`measureElement()` returns `{width: 0, height: 0}` when called during render (before layout). Always call it from `useEffect` or `useLayoutEffect`: + +```jsx +// WRONG - returns 0 +const ref = useRef(); +const size = measureElement(ref.current); // {width: 0, height: 0} + +// CORRECT +useEffect(() => { + const size = measureElement(ref.current); // actual dimensions +}, []); +``` + +## Percentage Width Requires Parent Size + +Percentage widths only work when the parent has an explicit size: + +```jsx +// WRONG - parent has no width, 50% of nothing is nothing + + Half + + +// CORRECT + + Half + +``` + +## Key Prop in Lists + +Always provide `key` when rendering lists, especially in ``: + +```jsx + + {item => ( + + {item.name} + + )} + +``` + +## Concurrent Mode Caveats + +When using `concurrent: true`: +- Some tests may need `act()` to properly await updates +- The option only takes effect on the first render for a given stdout +- Call `unmount()` first if you need to change rendering mode + +## Kitty Keyboard Protocol + +When enabled, input behavior changes: +- Non-printable keys (F1-F35, modifiers) produce empty `input` string +- Ctrl+letter works as expected (`input` is the letter, `key.ctrl` is true) +- `Ctrl+I` vs `Tab` are distinguished +- `key.eventType` reports `'press'`, `'repeat'`, or `'release'` + +## Common Errors + +### "Text string must be rendered inside a `` component" + +You have raw text outside ``. Wrap it. + +### "Objects are not valid as a React child" + +You're passing an object where text is expected. Convert to string first. + +### "Each child in a list should have a unique key prop" + +Add `key` to list items, especially in ``. diff --git a/.agents/skills/ink/references/core/patterns.md b/.agents/skills/ink/references/core/patterns.md new file mode 100644 index 0000000..fd7a003 --- /dev/null +++ b/.agents/skills/ink/references/core/patterns.md @@ -0,0 +1,342 @@ +# Common Patterns + +## Counter with Timer + +The most basic Ink pattern — state updates over time: + +```jsx +import React, {useState, useEffect} from 'react'; +import {render, Text} from 'ink'; + +const Counter = () => { + const [count, setCount] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCount(prev => prev + 1); + }, 100); + return () => clearInterval(timer); + }, []); + + return {count} tests passed; +}; + +render(); +``` + +## Task Runner (Static + Live) + +Display completed items permanently with ``, show live progress below: + +```jsx +import React, {useState, useEffect} from 'react'; +import {render, Static, Box, Text} from 'ink'; + +const TaskRunner = () => { + const [completed, setCompleted] = useState([]); + const [running, setRunning] = useState('Task 1'); + + return ( + <> + + {task => ( + + ✔ {task.name} + + )} + + + + Running: {running} + + + ); +}; +``` + +## Keyboard Navigation Menu + +Arrow keys to navigate, Enter to select, q to quit: + +```jsx +import React, {useState} from 'react'; +import {render, Box, Text, useInput, useApp} from 'ink'; + +const Menu = ({items}) => { + const [selected, setSelected] = useState(0); + const {exit} = useApp(); + + useInput((input, key) => { + if (input === 'q') exit(); + if (key.upArrow) setSelected(i => Math.max(0, i - 1)); + if (key.downArrow) setSelected(i => Math.min(items.length - 1, i + 1)); + if (key.return) handleSelect(items[selected]); + }); + + return ( + + {items.map((item, i) => ( + + {i === selected ? '> ' : ' '}{item} + + ))} + + ); +}; +``` + +## Chat/Text Input + +Handle character-by-character input with backspace: + +```jsx +import React, {useState} from 'react'; +import {render, Box, Text, useInput} from 'ink'; + +const TextInput = () => { + const [value, setValue] = useState(''); + const [messages, setMessages] = useState([]); + + useInput((input, key) => { + if (key.return) { + setMessages(prev => [...prev, {id: prev.length, text: value}]); + setValue(''); + return; + } + if (key.backspace || key.delete) { + setValue(prev => prev.slice(0, -1)); + return; + } + setValue(prev => prev + input); + }); + + return ( + + {messages.map(msg => ( + {msg.text} + ))} + {'> '}{value} + + ); +}; +``` + +## Focus Cycling + +Tab through focusable items: + +```jsx +import React from 'react'; +import {render, Box, Text, useFocus} from 'ink'; + +const FocusItem = ({label}) => { + const {isFocused} = useFocus(); + return ( + + {isFocused ? '>' : ' '} {label} + + ); +}; + +const App = () => ( + + + + + +); +``` + +## Subprocess Output + +Capture and display child process output: + +```jsx +import React, {useState, useEffect} from 'react'; +import {render, Text, Box} from 'ink'; +import {spawn} from 'child_process'; + +const SubprocessViewer = ({command, args}) => { + const [lines, setLines] = useState([]); + + useEffect(() => { + const child = spawn(command, args); + child.stdout.on('data', data => { + setLines(prev => [...prev.slice(-4), data.toString().trim()]); + }); + return () => child.kill(); + }, []); + + return ( + + {lines.map((line, i) => ( + {line} + ))} + + ); +}; +``` + +## Responsive Layout + +Adapt to terminal size: + +```jsx +import React from 'react'; +import {render, Box, Text, useStdout} from 'ink'; + +const Responsive = () => { + const {stdout} = useStdout(); + const isWide = stdout.columns > 80; + + return ( + + + Sidebar + + + Main content + + + ); +}; +``` + +## Progress Bar + +Visual progress indicator: + +```jsx +import React from 'react'; +import {Box, Text} from 'ink'; + +const ProgressBar = ({percent, width = 40}) => { + const filled = Math.round(width * percent); + const empty = width - filled; + + return ( + + {'█'.repeat(filled)} + {'░'.repeat(empty)} + {Math.round(percent * 100)}% + + ); +}; +``` + +## Table with Columns + +Fixed-width column layout: + +```jsx +import React from 'react'; +import {Box, Text} from 'ink'; + +const Table = ({data, columns}) => ( + + + {columns.map(col => ( + + {col.label} + + ))} + + {data.map((row, i) => ( + + {columns.map(col => ( + + {row[col.key]} + + ))} + + ))} + +); +``` + +## Router with React Router + +Navigate between views: + +```jsx +import React from 'react'; +import {render, Box, Text, useInput, useApp} from 'ink'; +import {MemoryRouter, Routes, Route, useNavigate} from 'react-router'; + +const Home = () => { + const navigate = useNavigate(); + useInput((input) => { + if (input === 's') navigate('/settings'); + }); + return Home (press s for settings); +}; + +const App = () => ( + + + } /> + } /> + + +); + +render(); +``` + +## Suspense with Data Fetching + +Use React Suspense for async data (requires `concurrent: true`): + +```jsx +import React, {Suspense} from 'react'; +import {render, Text} from 'ink'; + +const cache = new Map(); + +function fetchData(key) { + if (!cache.has(key)) { + const promise = loadData(key).then(data => { + cache.set(key, {status: 'resolved', data}); + }); + cache.set(key, {status: 'pending', promise}); + } + const entry = cache.get(key); + if (entry.status === 'pending') throw entry.promise; + return entry.data; +} + +const DataView = ({dataKey}) => { + const data = fetchData(dataKey); + return {data}; +}; + +render( + Loading...}> + + , + {concurrent: true}, +); +``` + +## Exit with Result + +Pass a value or error through exit: + +```jsx +import {render, useApp} from 'ink'; + +const App = () => { + const {exit} = useApp(); + + useEffect(() => { + doWork() + .then(result => exit(result)) // resolves waitUntilExit + .catch(error => exit(error)); // rejects waitUntilExit + }, []); + + return Working...; +}; + +const {waitUntilExit} = render(); +const result = await waitUntilExit(); +``` diff --git a/.agents/skills/ink/references/hooks/REFERENCE.md b/.agents/skills/ink/references/hooks/REFERENCE.md new file mode 100644 index 0000000..22c95cc --- /dev/null +++ b/.agents/skills/ink/references/hooks/REFERENCE.md @@ -0,0 +1,54 @@ +# Ink Hooks + +Reference for all built-in Ink hooks. + +## Hook Overview + +| Hook | Purpose | File | +|------|---------|------| +| `useInput` | Handle keyboard input | [input.md](./input.md) | +| `useApp` | Control app lifecycle (exit) | [app-lifecycle.md](./app-lifecycle.md) | +| `useStdin` | Access stdin stream and raw mode | [stdio.md](./stdio.md) | +| `useStdout` | Access stdout stream and dimensions | [stdio.md](./stdio.md) | +| `useStderr` | Access stderr stream and dimensions | [stdio.md](./stdio.md) | +| `useFocus` | Make component focusable | [focus.md](./focus.md) | +| `useFocusManager` | Programmatic focus control | [focus.md](./focus.md) | +| `useCursor` | Control cursor position (IME) | [focus.md](./focus.md) | +| `useIsScreenReaderEnabled` | Detect screen reader support | [focus.md](./focus.md) | + +## Hook Chooser + +``` +Need a hook? +├─ Handle keyboard (arrows, letters, shortcuts) -> input.md (useInput) +├─ Exit the app programmatically -> app-lifecycle.md (useApp) +├─ Read raw stdin / check raw mode -> stdio.md (useStdin) +├─ Get terminal width/height -> stdio.md (useStdout) +├─ Write to stderr -> stdio.md (useStderr) +├─ Make a component focusable (Tab cycling) -> focus.md (useFocus) +├─ Control focus programmatically -> focus.md (useFocusManager) +├─ Show/hide cursor for IME -> focus.md (useCursor) +└─ Check screen reader mode -> focus.md (useIsScreenReaderEnabled) +``` + +## Quick Import + +```jsx +import { + useInput, + useApp, + useStdin, + useStdout, + useStderr, + useFocus, + useFocusManager, + useCursor, + useIsScreenReaderEnabled, +} from 'ink'; +``` + +## See Also + +- [Components](../components/REFERENCE.md) - All components +- [Core API](../core/api.md) - render(), renderToString() +- [Patterns](../core/patterns.md) - Common patterns using hooks diff --git a/.agents/skills/ink/references/hooks/app-lifecycle.md b/.agents/skills/ink/references/hooks/app-lifecycle.md new file mode 100644 index 0000000..abc0384 --- /dev/null +++ b/.agents/skills/ink/references/hooks/app-lifecycle.md @@ -0,0 +1,118 @@ +# useApp + +Control the app lifecycle from within components. + +## Import + +```jsx +import {useApp} from 'ink'; +``` + +## Usage + +```jsx +const {exit} = useApp(); +``` + +## Returns + +### exit(error?) + +Exit the app. Unmounts the component tree and resolves or rejects the `waitUntilExit()` promise. + +```jsx +const {exit} = useApp(); + +// Normal exit — resolves waitUntilExit() +exit(); + +// Exit with error — rejects waitUntilExit() +exit(new Error('Something went wrong')); +``` + +### Exiting with a Result Value + +Pass any value to resolve `waitUntilExit()` with it: + +```jsx +// In component +const {exit} = useApp(); +exit(result); // resolves waitUntilExit with result + +// Outside +const {waitUntilExit} = render(); +const result = await waitUntilExit(); +``` + +## Patterns + +### Exit on Keypress + +```jsx +import {useApp, useInput} from 'ink'; + +const App = () => { + const {exit} = useApp(); + + useInput((input) => { + if (input === 'q') { + exit(); + } + }); + + return Press q to quit; +}; +``` + +### Exit After Task Completion + +```jsx +const App = ({task}) => { + const {exit} = useApp(); + + useEffect(() => { + runTask(task) + .then(() => exit()) + .catch(error => exit(error)); + }, []); + + return Running task...; +}; +``` + +### Exit with Error Handling + +```jsx +const run = async () => { + const {waitUntilExit} = render(); + + try { + await waitUntilExit(); + console.log('App exited successfully'); + } catch (error) { + console.error('App failed:', error.message); + process.exitCode = 1; + } +}; + +run(); +``` + +### Never Call process.exit() Directly + +Always use `useApp().exit()` from within components. `process.exit()` prevents cleanup: + +```jsx +// WRONG +process.exit(1); + +// CORRECT +const {exit} = useApp(); +exit(new Error('Fatal error')); +``` + +## See Also + +- [Core API](../core/api.md) - render(), waitUntilExit(), unmount() +- [Input](./input.md) - useInput for keyboard-triggered exit +- [Gotchas](../core/gotchas.md) - Process not exiting issues diff --git a/.agents/skills/ink/references/hooks/focus.md b/.agents/skills/ink/references/hooks/focus.md new file mode 100644 index 0000000..123bcc5 --- /dev/null +++ b/.agents/skills/ink/references/hooks/focus.md @@ -0,0 +1,285 @@ +# useFocus, useFocusManager, useCursor + +Focus management for interactive CLI components. + +## useFocus + +Make a component focusable. Users cycle through focusable components with Tab and Shift+Tab. + +### Import + +```jsx +import {useFocus} from 'ink'; +``` + +### Usage + +```jsx +const {isFocused, focus, id} = useFocus(); +``` + +### Returns + +| Property | Type | Description | +|----------|------|-------------| +| `isFocused` | `boolean` | Whether this component currently has focus | +| `focus` | `() => void` | Manually focus this component | +| `id` | `string` | Unique focus identifier | + +### Options + +```jsx +const {isFocused} = useFocus({ + autoFocus: false, // Auto-focus on mount (default: false) + isActive: true, // Participate in focus cycling (default: true) + id: 'my-component', // Custom focus ID (auto-generated if omitted) +}); +``` + +### Example + +```jsx +const FocusableItem = ({label}) => { + const {isFocused} = useFocus(); + + return ( + + + {isFocused ? '>' : ' '} {label} + + + ); +}; + +const App = () => ( + + + + + +); +``` + +### Auto Focus + +First rendered focusable component or the one with `autoFocus`: + +```jsx +// Second item gets initial focus + + + +``` + +### Disable Focus + +Remove from Tab cycling: + +```jsx +const {isFocused} = useFocus({isActive: false}); +// This component will be skipped during Tab cycling +``` + +--- + +## useFocusManager + +Programmatic control over the focus system. + +### Import + +```jsx +import {useFocusManager} from 'ink'; +``` + +### Usage + +```jsx +const {focus, focusNext, focusPrevious, enableFocus, disableFocus} = useFocusManager(); +``` + +### Returns + +| Method | Description | +|--------|-------------| +| `focus(id)` | Focus a specific component by its ID | +| `focusNext()` | Move focus to the next component | +| `focusPrevious()` | Move focus to the previous component | +| `enableFocus()` | Enable the focus system | +| `disableFocus()` | Disable the focus system | + +### Example: Custom Focus Navigation + +```jsx +const App = () => { + const {focusNext, focusPrevious} = useFocusManager(); + + useInput((input, key) => { + if (key.downArrow) focusNext(); + if (key.upArrow) focusPrevious(); + }); + + return ( + + + + + + ); +}; +``` + +### Example: Focus by ID + +```jsx +const App = () => { + const {focus} = useFocusManager(); + + useInput((input) => { + if (input === '1') focus('item-1'); + if (input === '2') focus('item-2'); + }); + + return ( + + + + + ); +}; +``` + +### Disable/Enable Focus System + +Temporarily disable Tab cycling (e.g., when a modal is open): + +```jsx +const {disableFocus, enableFocus} = useFocusManager(); + +// Disable Tab cycling while modal is open +useEffect(() => { + if (isModalOpen) { + disableFocus(); + } else { + enableFocus(); + } +}, [isModalOpen]); +``` + +--- + +## useCursor + +Show or hide the system cursor. Useful for text inputs with IME support. + +### Import + +```jsx +import {useCursor} from 'ink'; +``` + +### Usage + +```jsx +useCursor(visible); +``` + +### Parameter + +| Parameter | Type | Description | +|-----------|------|-------------| +| `visible` | `boolean` | Show (`true`) or hide (`false`) the cursor | + +### Example: Show Cursor When Focused + +```jsx +const TextInput = () => { + const {isFocused} = useFocus(); + useCursor(isFocused); + + return {isFocused ? '|' : ' '} Type here...; +}; +``` + +--- + +## useIsScreenReaderEnabled + +Check if screen reader mode is active. + +### Import + +```jsx +import {useIsScreenReaderEnabled} from 'ink'; +``` + +### Usage + +```jsx +const isEnabled = useIsScreenReaderEnabled(); +``` + +### Returns + +Type: `boolean` + +Whether screen reader support is enabled (via `INK_SCREEN_READER=true` env var or `isScreenReaderEnabled` render option). + +## Patterns + +### Focus-Aware Input Handling + +Combine `useFocus` with `useInput` for per-component keyboard handling: + +```jsx +const SelectableItem = ({label, onSelect}) => { + const {isFocused} = useFocus(); + + useInput( + (input, key) => { + if (key.return) { + onSelect(); + } + }, + {isActive: isFocused}, + ); + + return ( + + {isFocused ? '> ' : ' '}{label} + + ); +}; +``` + +### Multi-Step Form + +```jsx +const Form = () => { + const {focusNext} = useFocusManager(); + const [step, setStep] = useState(0); + + const handleSubmitField = () => { + if (step < totalSteps - 1) { + setStep(s => s + 1); + focusNext(); + } else { + submitForm(); + } + }; + + return ( + + + + + + ); +}; +``` + +## See Also + +- [Input](./input.md) - useInput for keyboard handling +- [Accessibility](../accessibility/REFERENCE.md) - Screen reader support +- [Patterns](../core/patterns.md) - Focus cycling pattern diff --git a/.agents/skills/ink/references/hooks/input.md b/.agents/skills/ink/references/hooks/input.md new file mode 100644 index 0000000..d3b72f6 --- /dev/null +++ b/.agents/skills/ink/references/hooks/input.md @@ -0,0 +1,180 @@ +# useInput + +Handle keyboard input from the user. + +## Import + +```jsx +import {useInput} from 'ink'; +``` + +## Basic Usage + +```jsx +useInput((input, key) => { + if (input === 'q') { + // User pressed 'q' + } + + if (key.leftArrow) { + // Left arrow pressed + } +}); +``` + +## Parameters + +### Callback: (input, key) => void + +#### input + +Type: `string` + +The character or string typed. For printable keys, this is the character itself. For non-printable keys (arrows, function keys), this is an empty string. + +#### key + +Type: `object` + +Key metadata: + +| Property | Type | Description | +|----------|------|-------------| +| `key.upArrow` | `boolean` | Up arrow key | +| `key.downArrow` | `boolean` | Down arrow key | +| `key.leftArrow` | `boolean` | Left arrow key | +| `key.rightArrow` | `boolean` | Right arrow key | +| `key.return` | `boolean` | Enter/Return key | +| `key.escape` | `boolean` | Escape key | +| `key.tab` | `boolean` | Tab key | +| `key.backspace` | `boolean` | Backspace key | +| `key.delete` | `boolean` | Delete key | +| `key.pageUp` | `boolean` | Page Up key | +| `key.pageDown` | `boolean` | Page Down key | +| `key.ctrl` | `boolean` | Ctrl modifier held | +| `key.meta` | `boolean` | Alt/Option modifier held | +| `key.shift` | `boolean` | Shift modifier held (arrows, Tab only) | + +### Options + +#### isActive + +Type: `boolean` | Default: `true` + +Enable/disable the handler. Useful for modals, focus-based input: + +```jsx +useInput(handler, {isActive: isFocused}); +``` + +## Patterns + +### Quit on 'q' + +```jsx +const {exit} = useApp(); + +useInput((input) => { + if (input === 'q') { + exit(); + } +}); +``` + +### Arrow Key Navigation + +```jsx +const [index, setIndex] = useState(0); + +useInput((input, key) => { + if (key.upArrow) { + setIndex(i => Math.max(0, i - 1)); + } + if (key.downArrow) { + setIndex(i => Math.min(items.length - 1, i + 1)); + } + if (key.return) { + handleSelect(items[index]); + } +}); +``` + +### Ctrl+Key Shortcuts + +```jsx +useInput((input, key) => { + if (key.ctrl && input === 's') { + save(); + } + if (key.ctrl && input === 'c') { + // Note: exitOnCtrlC handles this by default + exit(); + } +}); +``` + +### Conditional Input (Focus-Aware) + +Only handle input when component is focused: + +```jsx +const {isFocused} = useFocus(); + +useInput( + (input, key) => { + // Only fires when this component is focused + }, + {isActive: isFocused}, +); +``` + +### Character Input (Text Field) + +```jsx +const [text, setText] = useState(''); + +useInput((input, key) => { + if (key.backspace || key.delete) { + setText(prev => prev.slice(0, -1)); + return; + } + if (key.return) { + handleSubmit(text); + return; + } + if (input) { + setText(prev => prev + input); + } +}); +``` + +## Kitty Keyboard Protocol + +When `kittyKeyboard` is enabled in render options, the `key` object has additional properties: + +| Property | Type | Description | +|----------|------|-------------| +| `key.fn` | `boolean` | Fn key held | +| `key.numLock` | `boolean` | Num Lock on | +| `key.capsLock` | `boolean` | Caps Lock on | +| `key.scrollLock` | `boolean` | Scroll Lock on | +| `key.eventType` | `'press' \| 'repeat' \| 'release'` | Event type (with `reportEventTypes` flag) | + +Notable differences with kitty keyboard: +- Non-printable keys (F1-F35, modifiers alone) produce empty `input` +- `Ctrl+letter` works correctly (`input` is letter, `key.ctrl` is true) +- `Ctrl+I` and `Tab` are distinguished +- Key release events available with `reportEventTypes` flag + +## Notes + +- `useInput` puts stdin into raw mode automatically +- Multiple `useInput` hooks all receive events — coordinate with `isActive` +- Arrow keys set `key.shift` when Shift is held +- Letter input: `input` contains the character, not the key name + +## See Also + +- [Focus](./focus.md) - useFocus, useFocusManager +- [App Lifecycle](./app-lifecycle.md) - useApp for exit +- [Patterns](../core/patterns.md) - Common input patterns diff --git a/.agents/skills/ink/references/hooks/stdio.md b/.agents/skills/ink/references/hooks/stdio.md new file mode 100644 index 0000000..07e1519 --- /dev/null +++ b/.agents/skills/ink/references/hooks/stdio.md @@ -0,0 +1,167 @@ +# useStdin, useStdout, useStderr + +Access stdio streams and terminal dimensions from within components. + +## useStdin + +Access the stdin stream and check raw mode state. + +### Import + +```jsx +import {useStdin} from 'ink'; +``` + +### Usage + +```jsx +const {stdin, isRawModeSupported, setRawMode} = useStdin(); +``` + +### Returns + +| Property | Type | Description | +|----------|------|-------------| +| `stdin` | `ReadableStream` | The stdin stream | +| `isRawModeSupported` | `boolean` | Whether raw mode is available | +| `setRawMode` | `(mode: boolean) => void` | Enable/disable raw mode | + +### Raw Mode + +Raw mode sends individual keypresses to your app instead of line-buffered input. `useInput` enables raw mode automatically. + +```jsx +const {setRawMode, isRawModeSupported} = useStdin(); + +if (isRawModeSupported) { + setRawMode(true); // Enable raw mode +} +``` + +**Note:** `setRawMode` is reference-counted. Each call to `setRawMode(true)` increments a counter; `setRawMode(false)` decrements it. Raw mode stays on until all callers release it. + +### Reading Raw Data + +```jsx +const {stdin} = useStdin(); + +useEffect(() => { + const onData = (data) => { + console.log('stdin:', data.toString()); + }; + stdin.on('data', onData); + return () => stdin.off('data', onData); +}, []); +``` + +--- + +## useStdout + +Access the stdout stream and terminal dimensions. + +### Import + +```jsx +import {useStdout} from 'ink'; +``` + +### Usage + +```jsx +const {stdout, write} = useStdout(); +``` + +### Returns + +| Property | Type | Description | +|----------|------|-------------| +| `stdout` | `WritableStream` | The stdout stream | +| `write` | `(data: string) => void` | Write directly to stdout | + +### Terminal Dimensions + +```jsx +const {stdout} = useStdout(); +const columns = stdout.columns; // Terminal width +const rows = stdout.rows; // Terminal height +``` + +### Direct Write + +Write output outside Ink's rendering: + +```jsx +const {write} = useStdout(); +write('This bypasses Ink rendering\n'); +``` + +**Warning:** Direct writes mix with Ink output. Use sparingly. + +--- + +## useStderr + +Access the stderr stream for error output. + +### Import + +```jsx +import {useStderr} from 'ink'; +``` + +### Usage + +```jsx +const {stderr, write} = useStderr(); +``` + +### Returns + +| Property | Type | Description | +|----------|------|-------------| +| `stderr` | `WritableStream` | The stderr stream | +| `write` | `(data: string) => void` | Write directly to stderr | + +### Stderr Dimensions + +```jsx +const {stderr} = useStderr(); +const columns = stderr.columns; +const rows = stderr.rows; +``` + +## Patterns + +### Responsive to Terminal Size + +```jsx +const {stdout} = useStdout(); +const isWide = stdout.columns > 80; + +return ( + + + Sidebar + + + Content + + +); +``` + +### Logging to Stderr + +Write logs/debug info to stderr so they don't mix with stdout: + +```jsx +const {write} = useStderr(); +write(`[DEBUG] Processing item ${id}\n`); +``` + +## See Also + +- [Input](./input.md) - useInput for keyboard handling +- [Configuration](../core/configuration.md) - stdin/stdout/stderr options +- [Patterns](../core/patterns.md) - Responsive layout pattern diff --git a/.agents/skills/ink/references/layout/REFERENCE.md b/.agents/skills/ink/references/layout/REFERENCE.md new file mode 100644 index 0000000..5a2454a --- /dev/null +++ b/.agents/skills/ink/references/layout/REFERENCE.md @@ -0,0 +1,202 @@ +# Ink Layout System + +Ink uses [Yoga](https://github.com/facebook/yoga) to build Flexbox layouts in the terminal. Every `` is `display: flex` by default. + +## Overview + +Key concepts: +- **Flexbox model**: CSS Flexbox properties via Yoga engine +- **Terminal units**: Dimensions are in character cells (columns x rows) +- **No block/inline**: Only Flexbox layout is supported +- **Percentage support**: Relative sizing based on parent (parent must have explicit size) + +## flexDirection + +Controls the main axis: + +```jsx +// Row (default) - horizontal + + 1 + 2 + 3 + +// Output: 1 2 3 + +// Column - vertical + + 1 + 2 + 3 + +// Output: +// 1 +// 2 +// 3 + +// Reverse variants +... +... +``` + +## justifyContent + +Align children along the main axis: + +```jsx + {/* Start (default) */} + {/* Centered */} + {/* End */} +{/* First/last at edges, rest spaced */} + {/* Equal space around each child */} + {/* Equal space between all */} +``` + +## alignItems + +Align children along the cross axis: + +```jsx + {/* Top (for row) or left (for column) */} + {/* Centered on cross axis */} + {/* Bottom (for row) or right (for column) */} +``` + +## alignSelf + +Override parent's `alignItems` for one child: + +```jsx + + Centered + Bottom + +``` + +## flexGrow + +How much a child should grow relative to siblings: + +```jsx + + Fixed + Fills rest + +``` + +## flexShrink + +How much a child should shrink when space is limited: + +```jsx + + Shrinks + Fixed + +``` + +## flexBasis + +Initial size before growing/shrinking: + +```jsx + + Start 20, can grow + Half of parent + +``` + +## flexWrap + +Whether children wrap to new lines: + +```jsx + + A + B + +// B wraps to next line +``` + +## Gap + +Space between children: + +```jsx + {/* All directions */} + {/* Horizontal only */} + {/* Vertical only */} +``` + +## Dimensions + +### Fixed + +```jsx +... +``` + +### Percentage + +Parent must have explicit size: + +```jsx + + Half width + +``` + +### Min/Max Constraints + +```jsx +... +... +``` + +**Note:** `minWidth` and `maxWidth` do not support percentage values (Yoga limitation). + +## Padding + +Space inside the box: + +```jsx +... +... +... +``` + +## Margin + +Space outside the box: + +```jsx +... +... +... +``` + +## Display + +```jsx +... {/* Visible (default) */} +... {/* Hidden */} +``` + +## Overflow + +```jsx +... {/* Content can extend (default) */} +... {/* Content clipped */} +``` + +Axis-specific: + +```jsx +... +``` + +## See Also + +- [Layout Patterns](./patterns.md) - Common layout recipes +- [Box Component](../components/box.md) - Box props including borders +- [Spacer](../components/utilities.md) - Flexible space component diff --git a/.agents/skills/ink/references/layout/patterns.md b/.agents/skills/ink/references/layout/patterns.md new file mode 100644 index 0000000..c34f805 --- /dev/null +++ b/.agents/skills/ink/references/layout/patterns.md @@ -0,0 +1,216 @@ +# Layout Patterns + +Common layout recipes for Ink apps. + +## Centered Content + +```jsx + + Centered horizontally and vertically + +``` + +## Header / Content / Footer + +```jsx + + + My CLI App v1.0 + + + + Main content goes here + + + + Press q to quit + + +``` + +## Sidebar + Main + +```jsx + + + Menu + Item 1 + Item 2 + + + + Main content area + + +``` + +## Two Columns (Equal) + +```jsx + + Left column + Right column + +``` + +## Push Items Apart (Spacer) + +```jsx +import {Spacer} from 'ink'; + +// Horizontal: left + right + + Left + + Right + + +// Vertical: top + bottom + + Top + + Bottom + +``` + +## Status Bar + +```jsx + + READY + + Branch: main + | + 3 files changed + +``` + +## Table Layout + +```jsx +const columns = [ + {key: 'name', width: 20, label: 'Name'}, + {key: 'status', width: 10, label: 'Status'}, + {key: 'time', width: 8, label: 'Time'}, +]; + + + {/* Header */} + + {columns.map(col => ( + + {col.label} + + ))} + + {/* Rows */} + {data.map((row, i) => ( + + {columns.map(col => ( + + {row[col.key]} + + ))} + + ))} + +``` + +## Card Component + +```jsx +const Card = ({title, children}) => ( + + {title} + {children} + +); +``` + +## Loading Spinner + +```jsx +const Spinner = () => { + const [frame, setFrame] = useState(0); + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + + useEffect(() => { + const timer = setInterval(() => { + setFrame(f => (f + 1) % frames.length); + }, 80); + return () => clearInterval(timer); + }, []); + + return {frames[frame]}; +}; + +// Usage + + + Loading... + +``` + +## Responsive Layout + +```jsx +const App = () => { + const {stdout} = useStdout(); + const isNarrow = stdout.columns < 60; + + return ( + + + Sidebar + + + Content + + + ); +}; +``` + +## Progress with Label + +```jsx +const ProgressRow = ({label, percent, width = 30}) => { + const filled = Math.round(width * percent); + return ( + + + {label} + + {'█'.repeat(filled)} + {'░'.repeat(width - filled)} + {Math.round(percent * 100)}% + + ); +}; +``` + +## Indent/Nested List + +```jsx +const TreeItem = ({label, depth = 0, children}) => ( + + + {depth > 0 ? '├─ ' : ''}{label} + + {children} + +); +``` + +## See Also + +- [Layout Reference](./REFERENCE.md) - Flexbox properties +- [Box](../components/box.md) - Box component props +- [Spacer](../components/utilities.md) - Spacer component diff --git a/.agents/skills/ink/references/testing/REFERENCE.md b/.agents/skills/ink/references/testing/REFERENCE.md new file mode 100644 index 0000000..733727f --- /dev/null +++ b/.agents/skills/ink/references/testing/REFERENCE.md @@ -0,0 +1,281 @@ +# Testing Ink Applications + +Use `ink-testing-library` for headless testing of Ink components. + +## Setup + +```bash +npm install --save-dev ink-testing-library +``` + +## Import + +```jsx +import {render} from 'ink-testing-library'; +``` + +**Note:** Import `render` from `ink-testing-library`, not from `ink`. + +## Basic Test + +```jsx +import {render} from 'ink-testing-library'; +import {Text} from 'ink'; + +test('renders greeting', () => { + const {lastFrame} = render(Hello World); + expect(lastFrame()).toBe('Hello World'); +}); +``` + +## API + +### render(tree) + +Returns a test instance with these properties: + +| Property | Type | Description | +|----------|------|-------------| +| `lastFrame()` | `() => string \| undefined` | Last rendered output as string | +| `frames` | `string[]` | All rendered output frames | +| `stdin` | `object` | Object with `write()` for simulating input | +| `unmount()` | `() => void` | Unmount the component | +| `rerender(tree)` | `(element) => void` | Replace root node | +| `cleanup()` | `() => void` | Clean up the instance | + +### lastFrame() + +Get the last rendered output: + +```jsx +const {lastFrame} = render(Success); +expect(lastFrame()).toContain('Success'); +``` + +Returns `undefined` if there's no output yet. + +### frames + +Array of all rendered frames (useful for testing animations/transitions): + +```jsx +const {frames} = render(); +// frames[0] = '0' +// frames[1] = '1' +// ... +``` + +### stdin.write(data) + +Simulate keyboard input: + +```jsx +const {stdin, lastFrame} = render(); + +// Type text +stdin.write('hello'); + +// Press Enter +stdin.write('\r'); + +// Arrow keys +stdin.write('\u001B[A'); // Up +stdin.write('\u001B[B'); // Down +stdin.write('\u001B[C'); // Right +stdin.write('\u001B[D'); // Left + +// Escape +stdin.write('\u001B'); +``` + +### Common Key Sequences + +| Key | Sequence | +|-----|----------| +| Enter | `'\r'` | +| Escape | `'\u001B'` | +| Up Arrow | `'\u001B[A'` | +| Down Arrow | `'\u001B[B'` | +| Right Arrow | `'\u001B[C'` | +| Left Arrow | `'\u001B[D'` | +| Tab | `'\t'` | +| Shift+Tab | `'\u001B[Z'` | +| Backspace | `'\x7F'` | +| Delete | `'\u001B[3~'` | +| Ctrl+C | `'\x03'` | +| Space | `' '` | + +### unmount() + +Unmount the component tree: + +```jsx +const {unmount} = render(); +unmount(); +``` + +### rerender(tree) + +Replace the entire component tree: + +```jsx +const {rerender, lastFrame} = render(); +expect(lastFrame()).toContain('Alice'); + +rerender(); +expect(lastFrame()).toContain('Bob'); +``` + +### cleanup() + +Clean up internal resources: + +```jsx +const instance = render(); +// ... tests ... +instance.cleanup(); +``` + +## Testing Patterns + +### Test Component Output + +```jsx +test('displays status', () => { + const {lastFrame} = render(); + expect(lastFrame()).toContain('Done'); +}); +``` + +### Test User Input + +```jsx +import {delay} from 'ink-testing-library'; + +test('handles keyboard input', async () => { + const {stdin, lastFrame} = render(); + + // Move down + await delay(100); + stdin.write('\u001B[B'); // Down arrow + await delay(100); + + expect(lastFrame()).toContain('> B'); +}); +``` + +### Test with State Changes + +```jsx +test('counter increments', async () => { + const {lastFrame} = render(); + + expect(lastFrame()).toContain('Count: 0'); + + // Wait for state update + await delay(200); + + expect(lastFrame()).toContain('Count: 1'); +}); +``` + +### Snapshot Testing + +```jsx +test('layout matches snapshot', () => { + const {lastFrame} = render(); + expect(lastFrame()).toMatchSnapshot(); +}); +``` + +### Test Props Update + +```jsx +test('updates on prop change', () => { + const {rerender, lastFrame} = render(); + expect(lastFrame()).toContain('5'); + + rerender(); + expect(lastFrame()).toContain('10'); +}); +``` + +### Test Focus Cycling + +```jsx +test('tab cycles focus', async () => { + const {stdin, lastFrame} = render( + <> + + + , + ); + + await delay(100); + stdin.write('\t'); // Tab + await delay(100); + + expect(lastFrame()).toContain('> Second'); +}); +``` + +### Test Exit + +```jsx +test('exits on q', async () => { + const {stdin, lastFrame} = render(); + + stdin.write('q'); + await delay(100); + + // App should have unmounted +}); +``` + +## Async Testing with delay + +Use `delay` from `ink-testing-library` to wait for state updates: + +```jsx +import {render, delay} from 'ink-testing-library'; + +test('async update', async () => { + const {lastFrame} = render(); + + expect(lastFrame()).toContain('Loading'); + + await delay(1000); + + expect(lastFrame()).toContain('Loaded'); +}); +``` + +## Gotchas + +### Timing + +State updates in React are async. Use `await delay(ms)` between input and assertion. + +### ANSI Codes in Output + +`lastFrame()` includes ANSI escape codes for colors. Use `toContain` for partial matching rather than exact `toBe` when colors are involved. + +### CI Environment + +Tests run in CI mode by default when `CI` env var is set. This affects rendering behavior. + +### Cleanup + +Always clean up or unmount after tests to prevent resource leaks: + +```jsx +afterEach(() => { + instance?.cleanup(); +}); +``` + +## See Also + +- [Core API](../core/api.md) - render(), renderToString() +- [Input](../hooks/input.md) - useInput for keyboard handling +- [Focus](../hooks/focus.md) - Focus management in tests diff --git a/.agents/skills/ink/rules/RULES.md b/.agents/skills/ink/rules/RULES.md new file mode 100644 index 0000000..e11ef42 --- /dev/null +++ b/.agents/skills/ink/rules/RULES.md @@ -0,0 +1,25 @@ +# Ink Best Practices + +Best practices extracted from deep analysis of the Ink source code, tests, and examples. + +## Rule Files + +| File | Topic | +|------|-------| +| [performance.md](./performance.md) | FPS tuning, Static, memoization, incremental rendering | +| [components.md](./components.md) | Box, Text, Static, Transform, Newline, Spacer | +| [hooks.md](./hooks.md) | useInput, useApp, useFocus, useFocusManager, useCursor | +| [core.md](./core.md) | render(), renderToString(), error handling, environment behavior | + +## Critical Rules (Always Apply) + +1. **ALL text must be inside ``** — raw strings in `` throw runtime errors +2. **`` is a leaf** — cannot contain `` or structural components +3. **`` is always flexbox** — cannot disable `display: flex` +4. **NEVER use `process.exit()`** — use `useApp().exit()` for proper cleanup +5. **Tab/Shift+Tab are reserved** — intercepted by the focus system, not received by `useInput` +6. **Ctrl+C is intercepted** — by default it exits the app; set `exitOnCtrlC: false` to handle it yourself +7. **`` items are immutable** — only append new items; mutations to existing items are ignored +8. **Always `key` in ``** — each rendered element needs a stable, unique `key` +9. **Check `isRawModeSupported`** — raw mode is unavailable in CI and piped input scenarios +10. **Yoga units are characters/lines** — not pixels; `padding={1}` = 1 character horizontally, 1 line vertically diff --git a/.agents/skills/ink/rules/components.md b/.agents/skills/ink/rules/components.md new file mode 100644 index 0000000..1082eda --- /dev/null +++ b/.agents/skills/ink/rules/components.md @@ -0,0 +1,336 @@ +# Component Best Practices + +## `` + +### Rule: Box is Always Flexbox + +Every `` is `display: flex`. You cannot disable it. Default axis is **row** (horizontal). + +```jsx +// Horizontal layout (default) + + Left + Right + + +// Vertical layout + + Top + Bottom + +``` + +### Rule: All Text Must Be Inside `` + +Raw strings directly in `` throw a runtime error: + +```jsx +// WRONG — throws: "Text string "Hello" must be rendered inside " +Hello + +// CORRECT +Hello +``` + +### Rule: Yoga Units Are Characters, Not Pixels + +`padding`, `margin`, `width`, `height` are measured in **character cells** (horizontal) and **lines** (vertical): + +```jsx + // 1 space on each side + // 2 blank lines above + // 40 characters wide +``` + +Percentage strings work when the parent has an explicit size: + +```jsx +// WRONG — parent has no width, 50% = nothing +... + +// CORRECT +... +``` + +### Rule: Understand Background Color Inheritance + +`backgroundColor` on `` fills the **entire box area**. Child `` automatically inherits this background via React context: + +```jsx + + This text has green background + This overrides to red (text glyphs only) + +``` + +Key distinction: `Box` fills the box area; `Text` only colors the character glyphs. + +### Rule: Use `position: 'absolute'` for Overlay Elements + +Ink supports `relative` (default) and `absolute` positioning. Absolute elements are positioned relative to the root: + +```jsx + + Main content + + Overlay badge + + +``` + +### Rule: Use `overflow: 'hidden'` to Clip Content + +Content that exceeds box dimensions can be clipped: + +```jsx + + This very long text gets clipped at 20 chars + +``` + +### Common Box Flex Defaults vs CSS + +| Property | Ink Default | CSS Default | +|----------|-------------|-------------| +| `flexWrap` | `'nowrap'` | `'wrap'` | +| `flexDirection` | `'row'` | `'row'` | +| `flexGrow` | `0` | `0` | +| `flexShrink` | `1` | `1` | +| `alignItems` | `'stretch'` | `'stretch'` | + +Note: Ink **does not wrap by default** — set `flexWrap="wrap"` explicitly if needed. + +--- + +## `` + +### Rule: Text is a Leaf Component + +`` cannot contain `` or structural components: + +```jsx +// WRONG — throws: " can't be nested inside " +Hello World + +// CORRECT — nest Text inside Text +Hello World +``` + +### Rule: Null and Undefined Children Are Safe + +`` with `undefined` or `null` children renders nothing (no error): + +```jsx +// All fine — renders nothing +{undefined} +{null} +{condition && value} +``` + +### Rule: Know ANSI Sequence Handling + +`` strips cursor movement sequences but preserves color/style codes: + +| Sequence Type | Behavior | +|---|---| +| SGR color (`\x1b[32m`, `\x1b[0m`) | **Preserved** | +| OSC hyperlinks (`\x1b]8;;URL\x07`) | **Preserved** | +| Cursor movement (`\x1b[1A`, `\x1b[2K`) | **Stripped** | +| Cursor position (`\x1b[5;10H`) | **Stripped** | + +If you're passing pre-colored strings (e.g. from `chalk`), they work correctly. + +### Rule: Choose the Right `wrap` Mode + +```jsx + + Hello World // "Hello\nWorld" (default) + Hello World // "Hello W…" + Hello World // "Hel…rld" + Hello World // "…o World" + +``` + +Use `truncate` variants for single-line displays (status bars, file paths, etc.). + +### Rule: Wide Characters Work Correctly + +CJK, emoji, and emoji with variation selectors are handled. Terminal output aligns correctly: + +```jsx +こんにちは // 10 columns wide (each char = 2) +🎉 Done! // emoji measured correctly +🌡️ Temp // variation selector handled +``` + +--- + +## `` + +### Rule: Static Only for Append-Only Output + +`` renders items **once**, permanently. It tracks which items have been rendered and only processes new ones. + +```jsx +// Correct usage — items only grow +const [completed, setCompleted] = useState([]); +// Add new item: +setCompleted(prev => [...prev, {id: Date.now(), text: 'Task done'}]); + + + {item => ✔ {item.text}} + +``` + +### Rule: Never Mutate Items Already in Static + +Previously rendered items are permanently in the terminal output. Mutations are ignored: + +```jsx +// WRONG — update to existing item won't re-render it +setCompleted(prev => + prev.map(item => item.id === id ? {...item, status: 'failed'} : item) +); + +// CORRECT — show updates in a separate dynamic area, or append a new item +setCompleted(prev => [...prev, {id: Date.now(), text: 'Task FAILED', type: 'error'}]); +``` + +### Rule: Always Use Stable Keys in Static + +```jsx +// WRONG — index as key breaks if items are ever reordered + + {(item, index) => ...} + + +// CORRECT — stable item ID + + {item => ...} + +``` + +### Understanding Static Rendering Order + +Output: Static content appears **above** dynamic content: + +``` +[static item 1] +[static item 2] +[dynamic content - updates in place] +``` + +--- + +## `` + +### Rule: Transform Receives Output Per Line + +The `transform` function is called once per line of rendered output: + +```jsx +// index = line number (0-based) + `[${index}] ${line}`}> + Line one{'\n'}Line two + +// Output: +// [0] Line one +// [1] Line two +``` + +### Rule: Keep Transform Functions Pure and Fast + +Transform is called every render, for every line of content: + +```jsx +// BAD — closure with state, potential side effects + { count++; return line.toUpperCase(); }}> + +// GOOD — pure function +const toUpper = (line) => line.toUpperCase(); + +``` + +### Rule: Use `accessibilityLabel` for Screen Reader Compatibility + +When visual transforms produce non-readable output (gradients, box-drawing), provide an accessible label: + +```jsx + + ✓✓✓ Done ✓✓✓ + +``` + +--- + +## `` + +### Rule: Newline Must Be Inside `` + +`` renders `\n` characters and must live inside a `` context: + +```jsx +// WRONG — Newline is not a direct Box child + + Line 1 + + Line 2 + + +// CORRECT — inside Text + + Line 1 + + Line 2 + + +// For multi-line layouts, use flexDirection="column" on Box instead + + Line 1 + Line 2 + +``` + +--- + +## `` + +### Rule: Spacer Fills Remaining Space on Main Axis + +`` is ``. It expands to fill available space: + +```jsx +// Push items to opposite ends (horizontal) + + Left + + Right + + +// Push to top/bottom (vertical) + + Top + + Bottom + +``` + +The parent `` must have a defined size (explicit or constrained by terminal) for `Spacer` to have space to fill. + +--- + +## Anti-Patterns Summary + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| `Hello` | Runtime error | `Hello` | +| `...` | Runtime error | Flatten or nest `` in `` | +| Inline style `{{padding: 1}}` | Yoga recalc every render | Hoist or `useMemo` | +| Mutating `` items | Silently ignored | Append new items instead | +| `` with array index key | Breaks on reorder | Use stable item ID | +| `` as direct `` child | Wrong output | Use `flexDirection="column"` | +| `` in unsized parent | No effect | Ensure parent has constrained size | +| `width="50%"` without parent size | No effect | Set parent `width` explicitly | diff --git a/.agents/skills/ink/rules/core.md b/.agents/skills/ink/rules/core.md new file mode 100644 index 0000000..d22cdf8 --- /dev/null +++ b/.agents/skills/ink/rules/core.md @@ -0,0 +1,315 @@ +# Core / Render API Best Practices + +## `render()` + +### Rule: Always `await waitUntilExit()` + +Without `await`, the process may exit before cleanup completes. Always await: + +```jsx +const {waitUntilExit} = render(); + +try { + const result = await waitUntilExit(); // resolves with exit(value) + process.exit(0); +} catch (error) { + console.error(error.message); // rejects with exit(new Error(...)) + process.exit(1); +} +``` + +### Rule: One `render()` Per `stdout` Stream + +Ink caches one instance per stdout. Calling `render()` twice with the same stdout reuses the same instance and ignores the second call's options. To reset: + +```jsx +const {cleanup} = render(, {stdout: process.stdout}); +cleanup(); // clears the cache +render(, {stdout: process.stdout}); // fresh instance +``` + +This is important in tests — always call `cleanup()` between test cases. + +### Rule: `concurrent` Mode Is Immutable After First Render + +The `concurrent` option only takes effect on the first `render()` call for a given stdout. Changing it on subsequent calls has no effect (Ink logs a warning): + +```jsx +// To switch concurrent mode: +const {unmount, cleanup} = render(, {concurrent: false}); +unmount(); +cleanup(); +render(, {concurrent: true}); // now takes effect +``` + +### Rule: Keep `patchConsole: true` (Default) + +Without console patching, `console.log()` writes directly to stdout and corrupts Ink's rendered output. The default patches `console.log`, `console.error`, etc. to render above Ink's output: + +```jsx +// Only disable if you have a specific reason +render(, {patchConsole: false}); // risk: console.log corrupts output +``` + +### Rule: Use `debug: true` Only in Development + +Debug mode disables throttling and accumulates all rendered frames without clearing. Never ship with `debug: true`: + +```jsx +// DEV only — shows each render separately in terminal scrollback +render(, {debug: process.env.NODE_ENV === 'development'}); +``` + +### Rule: Use `exitOnCtrlC: false` to Handle Ctrl+C Yourself + +The default `exitOnCtrlC: true` intercepts Ctrl+C before any `useInput` handler: + +```jsx +render(, {exitOnCtrlC: false}); +// Now useInput handlers receive Ctrl+C as: input='' key.ctrl=true key.name='c' +``` + +--- + +## `renderToString()` + +### Rule: Use for Testing, Not Production Rendering + +`renderToString()` is synchronous, creates no terminal bindings, and is safe to call anywhere. Use it for snapshot tests and assertion-based testing: + +```jsx +import {renderToString, Box, Text} from 'ink'; + +const output = renderToString( + + Hello + , + {columns: 40}, // virtual terminal width +); + +expect(output).toMatch('Hello'); +``` + +### Rule: `useEffect` Does Not Affect `renderToString` Output + +`useEffect` runs after render but the returned string is captured before effects execute. Only `useLayoutEffect` affects the output: + +```jsx +// Effect fires but output is already captured +const Component = () => { + const [loaded, setLoaded] = useState(false); + useEffect(() => setLoaded(true), []); // too late + + return {loaded ? 'Loaded' : 'Loading'}; +}; + +renderToString(); // Returns 'Loading', not 'Loaded' +``` + +### Rule: All Terminal Hooks Return No-Ops in `renderToString` + +`useInput`, `useApp`, `useStdin`, `useFocus`, etc. return safe defaults and do nothing. This makes `renderToString` safe to call with any component that uses these hooks. + +### Rule: Yoga Nodes Are Freed Automatically + +`renderToString` wraps everything in a try/finally to free Yoga WASM memory even on error. You don't need manual cleanup. + +--- + +## Error Handling + +### Rule: Use `` for Render Errors + +Ink wraps your app in an `ErrorBoundary` automatically. Render errors display an error panel in the terminal. To handle them yourself, wrap specific subtrees: + +```jsx +import {ErrorBoundary} from 'react'; // standard React ErrorBoundary + +class MyErrorBoundary extends React.Component { + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return Error: {this.state.error.message}; + } + return this.props.children; + } +} +``` + +### Rule: Distinguish Errors from Exit Results + +`exit()` differentiates errors from values: + +```jsx +const {exit} = useApp(); + +// This REJECTS waitUntilExit (thrown as error) +exit(new Error('something failed')); + +// This RESOLVES waitUntilExit (returned as value) +exit({files: 42, duration: 1200}); +exit('done'); +exit(); // resolves with undefined +``` + +Only instances of `Error` (or objects whose `toString` is `[object Error]`) are treated as rejections. + +--- + +## Environment-Specific Behavior + +### Rule: CI Mode Renders Differently + +Ink detects CI via the `CI` environment variable. In CI: +- Only the **final frame** is written to stdout (no terminal clearing) +- A trailing newline is added after the last render +- Raw mode is typically unavailable (`isRawModeSupported === false`) + +To force full interactive rendering in CI: +``` +CI=false node my-app.js +``` + +### Rule: Handle Non-TTY stdout (Piped Output) + +When stdout is not a TTY (e.g., `node app.js | tee output.txt`): +- `stdout.columns` is `undefined` → Ink defaults to 80 columns +- `isTTY` is `false` → no ANSI clearing, no cursor manipulation +- Raw mode is unavailable + +```jsx +const {stdout} = useStdout(); +const width = stdout.columns ?? 80; // always guard with fallback +const isTTY = stdout.isTTY ?? false; +``` + +### Rule: Screen Reader Mode Changes All Output + +Enable with `INK_SCREEN_READER=true` or `render(, {isScreenReaderEnabled: true})`: +- Visual formatting (borders, colors) is stripped +- `aria-label` is used instead of visual content +- `flexDirection` determines separator (row → space, column → newline) +- ANSI escape codes are removed + +Test your app's screen reader output: +```jsx +const output = renderToString(, {isScreenReaderEnabled: true}); +``` + +--- + +## Concurrent Mode + +### Rule: Enable Concurrent Mode for Suspense + +React Suspense and `useTransition` require concurrent mode: + +```jsx +render( + Loading…}> + + , + {concurrent: true}, +); +``` + +### Rule: Concurrent Mode Tests Need `act()` + +State updates in concurrent mode may not flush synchronously. Wrap test assertions in `act()`: + +```jsx +import {act} from 'react'; + +await act(async () => { + // trigger async state updates +}); +// Now assert +``` + +--- + +## Cleanup & Unmounting + +### Rule: Unmount Properly in Tests + +Each test should unmount and clean up the Ink instance: + +```jsx +afterEach(() => { + instance.unmount(); + instance.cleanup(); // clears instance cache for next test +}); +``` + +### Rule: Throttle Flushes on Unmount + +On `unmount()`, Ink: +1. Flushes any pending throttled renders (writes final frame) +2. Settles the throttled log writer +3. Restores the console +4. Disables Kitty keyboard protocol if enabled +5. Unmounts the React tree +6. Removes the instance from the cache +7. Resolves/rejects `waitUntilExit()` + +This is automatic — you just need to call `unmount()` or `exit()`. + +### Rule: Signal Handling Is Automatic + +Ink uses `signal-exit` to register cleanup on `SIGINT`, `SIGTERM`, and `beforeExit`. If the process exits via a signal, Ink still cleans up. You don't need to register your own signal handlers for terminal cleanup. + +--- + +## Kitty Keyboard Protocol + +### Rule: Opt In Explicitly + +Kitty keyboard is disabled by default. Enable when you need: +- Key **release** events (`key.eventType === 'release'`) +- Key **repeat** distinction (`key.eventType === 'repeat'`) +- Disambiguation of `Ctrl+I` vs `Tab` +- Modifier-only key events + +```jsx +render(, { + kittyKeyboard: { + mode: 'auto', // auto-detect kitty/WezTerm/ghostty + flags: ['disambiguateEscapeCodes', 'reportEventTypes'], + }, +}); +``` + +`mode: 'auto'` checks for known terminals (`KITTY_WINDOW_ID`, `TERM=xterm-kitty`, `TERM_PROGRAM=WezTerm`, `TERM_PROGRAM=ghostty`) and queries the terminal for protocol support. + +### Rule: Always Handle Both Kitty and Legacy Input + +Users may run your app in terminals that don't support the Kitty protocol. Design input handling to work without `key.eventType`: + +```jsx +useInput((input, key) => { + // Works in all terminals + if (key.return) { /* submit */ } + + // Only meaningful in kitty-capable terminals + if (key.eventType === 'release' && key.ctrl && input === 's') { + // Optional: special release handling + } +}); +``` + +--- + +## Anti-Patterns Summary + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| No `await waitUntilExit()` | Process exits before cleanup | Always await | +| Two `render()` to same stdout | Second options ignored | `cleanup()` between renders | +| `debug: true` in production | Accumulates all frames | Only in development | +| `patchConsole: false` | `console.log` corrupts output | Keep default `true` | +| Using Suspense without `concurrent: true` | Suspense silently broken | Enable concurrent mode | +| No `cleanup()` in tests | Instance leaks between tests | Call `cleanup()` in `afterEach` | +| Assuming CI supports raw mode | Throws in CI | Check `isRawModeSupported` | +| Assuming `stdout.columns` is set | `undefined` when not TTY | Guard with `?? 80` | diff --git a/.agents/skills/ink/rules/hooks.md b/.agents/skills/ink/rules/hooks.md new file mode 100644 index 0000000..5f0bb24 --- /dev/null +++ b/.agents/skills/ink/rules/hooks.md @@ -0,0 +1,344 @@ +# Hook Best Practices + +## `useInput` + +### Rule: Tab and Shift+Tab Are Reserved + +`Tab` and `Shift+Tab` are intercepted by the focus system and never reach `useInput` handlers. If you need custom Tab behavior, disable focus first: + +```jsx +const {disableFocus} = useFocusManager(); +disableFocus(); // now Tab reaches useInput +useInput((input, key) => { + if (key.tab) { /* your custom logic */ } +}); +``` + +### Rule: Ctrl+C Is Intercepted By Default + +`exitOnCtrlC: true` (the default) means Ctrl+C exits the app before any `useInput` handler sees it: + +```jsx +// To handle Ctrl+C yourself: +render(, {exitOnCtrlC: false}); + +useInput((input, key) => { + if (key.ctrl && input === 'c') { + // Now you receive it + performCleanup(); + exit(); + } +}); +``` + +### Rule: Check `isRawModeSupported` in CI / Piped Input + +Raw mode is unavailable when stdin is not a TTY (CI environments, piped input). `useInput` will silently not fire: + +```jsx +const {isRawModeSupported} = useStdin(); + +useEffect(() => { + if (!isRawModeSupported) { + // Fallback: read from args, config, or skip interactive behavior + } +}, [isRawModeSupported]); +``` + +### Rule: Use `isActive` to Coordinate Multiple `useInput` Hooks + +All registered `useInput` handlers receive events unless you disable them. Coordinate with `isActive`: + +```jsx +// Modal pattern — disable background input while modal is open +const [modalOpen, setModalOpen] = useState(false); + +useInput(backgroundHandler, {isActive: !modalOpen}); +useInput(modalHandler, {isActive: modalOpen}); +``` + +### Rule: Pasted Text Fires the Handler Once with the Full String + +When a user pastes multi-character text, `useInput` fires **once** with the entire paste as `input`: + +```jsx +useInput((input, key) => { + // input might be "Hello World" from a paste + // key.return is false for pasted text + if (input.length > 1) { + handlePaste(input); + return; + } + handleSingleChar(input, key); +}); +``` + +### Rule: Uppercase Letters Always Set `key.shift = true` + +This is a legacy behavior in Ink. If the user types `A`, `key.shift` is `true` regardless of whether the physical Shift key was pressed: + +```jsx +useInput((input, key) => { + // Don't rely on key.shift to detect Shift+letter combos + // Instead, check input case directly + if (input === 'A') { /* uppercase A */ } + if (input === 'a') { /* lowercase a */ } +}); +``` + +### Rule: Non-Printable Keys Produce Empty `input` + +Arrow keys, F-keys, and modifier-only keys give `input === ''`. Use the `key` object instead: + +```jsx +useInput((input, key) => { + if (key.upArrow) { /* ← use key, not input */ } + if (key.return) { /* ← use key, not input */ } + if (input) { /* only truthy for printable characters */ } +}); +``` + +### Rule: Wrap Input State Updates in `reconciler.discreteUpdates` + +Ink does this internally — your state updates from `useInput` automatically get **discrete (high) priority** in React concurrent mode. You don't need to do anything extra, but it means input updates are never deferred or batched with lower-priority work. + +--- + +## `useApp` + +### Rule: Always Use `exit()` Instead of `process.exit()` + +`process.exit()` aborts the process without cleanup, leaving the terminal in raw mode: + +```jsx +// WRONG — terminal stays in raw mode, cursor hidden, garbled output +process.exit(0); + +// CORRECT — disables raw mode, restores stdin, resolves waitUntilExit +const {exit} = useApp(); +exit(); +``` + +| Aspect | `exit()` | `process.exit()` | +|---|---|---| +| Disables raw mode | ✅ | ❌ | +| Restores cursor | ✅ | ❌ | +| Resolves `waitUntilExit()` | ✅ | ❌ | +| React cleanup | ✅ | ❌ | + +### Rule: Pass Errors and Results via `exit()` + +```jsx +const {exit} = useApp(); + +// Signal success with a value +exit('success'); // waitUntilExit() resolves with 'success' + +// Signal failure with an Error +exit(new Error('timeout')); // waitUntilExit() rejects with the error + +// Plain exit +exit(); // resolves with undefined +``` + +### Rule: Do Not Call `exit()` During Effect Cleanup + +Effect cleanup runs during React's unmount phase. Calling `exit()` there can cause re-entry: + +```jsx +// RISKY — calling exit inside cleanup +useEffect(() => { + return () => exit(); // Don't do this +}, []); + +// CORRECT — schedule exit asynchronously if needed +useEffect(() => { + return () => setTimeout(exit, 0); +}, [exit]); +``` + +### Rule: Raw Mode Blocks Exit Until Released + +If any `useInput` or `useFocus` hook has raw mode enabled, `exit()` waits for raw mode to be released before resolving. This is automatic — you don't need to manage it. + +--- + +## `useFocus` / `useFocusManager` + +### Rule: Escape Resets Focus to `undefined` + +Pressing Escape clears focus. The user must press Tab again to focus a component. This is intentional and not configurable: + +```jsx +const {isFocused} = useFocus(); +// After user presses Escape: isFocused === false for all components +``` + +### Rule: Unmounting a Focused Component Resets Focus + +When the currently focused component unmounts, focus resets to `undefined`. It does **not** automatically move to the next component: + +```jsx +// If FocusedItem unmounts while focused, user must Tab to refocus +{showItem && } +``` + +To auto-focus another component on unmount, use programmatic focus in an effect. + +### Rule: Multiple `autoFocus` Components — Only First Wins + +If multiple components have `autoFocus: true`, only the first registered one gets focus: + +```jsx +// Only "First" gets autoFocus + + // this is ignored +``` + +### Rule: Disabled Components Are Skipped in Tab Navigation + +Components with `isActive: false` stay registered but are invisible to Tab cycling: + +```jsx +const {isFocused} = useFocus({isActive: !isDisabled}); +``` + +If all components are disabled, Tab does nothing. + +### Rule: `focus(id)` on Non-Existent ID Is Silent + +Calling `focus('wrong-id')` silently does nothing. Ensure IDs match: + +```jsx +// Register with explicit ID +const {isFocused} = useFocus({id: 'email-field'}); + +// Focus it programmatically +const {focus} = useFocusManager(); +focus('email-field'); // ← must match exactly +``` + +### Rule: `focusNext()` / `focusPrevious()` Wraps Around + +When at the last item, `focusNext()` wraps to the first. When at the first, `focusPrevious()` wraps to the last: + +```jsx +// Use for arrow-key navigation in lieu of Tab +const {focusNext, focusPrevious} = useFocusManager(); +useInput((_, key) => { + if (key.downArrow) focusNext(); + if (key.upArrow) focusPrevious(); +}); +``` + +### Rule: Tab Order Is Component Mount Order + +Components are focusable in the order they mount. If you need a different order, use `useFocusManager().focus(id)` programmatically rather than relying on Tab order. + +### Rule: `activeId` Triggers Re-Renders for All Focusable Components + +When focus changes, all components using `useFocus()` re-render to recalculate `isFocused`. For expensive renders, wrap the output in `useMemo`: + +```jsx +const {isFocused} = useFocus(); +const content = useMemo(() => , [data]); + +return ( + + {content} + +); +``` + +--- + +## `useCursor` + +### Rule: Use `useInsertionEffect` Semantics Internally + +`useCursor` uses `useInsertionEffect` (not `useEffect`) to synchronize cursor position. This ensures cursor state from abandoned renders (e.g. during Suspense) is discarded correctly. You don't call this yourself, but it explains why cursor state is always consistent. + +### Rule: Provide Cursor Position Relative to Ink's Output Area + +Coordinates are relative to the **top-left of Ink's rendered area**, not the full terminal: + +```jsx +const {setCursorPosition} = useCursor(); +// x = character offset from left edge of Ink output +// y = line offset from top of Ink output +setCursorPosition({x: stringWidth(prompt + text), y: 0}); +``` + +### Rule: Cursor Hides Automatically on Unmount + +When the component using `useCursor` unmounts, the cursor is hidden automatically via the cleanup in `useInsertionEffect`. No manual cleanup needed. + +--- + +## `useStdin` / `useStdout` / `useStderr` + +### Rule: Use `useStdout()` for Terminal Width + +```jsx +const {stdout} = useStdout(); +const width = stdout.columns ?? 80; // columns is undefined when not TTY + +// Responsive layout +const isWide = width > 100; +``` + +### Rule: Use `useStderr()` for Error Output That Doesn't Corrupt UI + +Writing to stdout directly competes with Ink's rendered output. Write errors to stderr: + +```jsx +const {stderr} = useStderr(); +stderr.write(`Error: ${message}\n`); +``` + +### Rule: Never Call `setRawMode` Directly + +`useStdin()` exposes `setRawMode` but it uses reference counting internally. Calling `stdin.setRawMode()` directly bypasses the counter and can disable raw mode while other hooks still need it: + +```jsx +// WRONG — bypasses refcount +const {stdin} = useStdin(); +stdin.setRawMode(false); // breaks other hooks + +// CORRECT — use the wrapped version +const {setRawMode} = useStdin(); +setRawMode(false); // reference-counted +``` + +--- + +## `useIsScreenReaderEnabled` + +### Rule: Provide `aria-label` on Visual-Only Boxes + +When rendering visual decoration that means nothing to a screen reader: + +```jsx +const isScreenReader = useIsScreenReaderEnabled(); + +// Box with aria-label — screen reader sees "Status: OK" instead of visual content + + ■■■■■□□□□□ + +``` + +--- + +## Anti-Patterns Summary + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| `process.exit()` | Terminal left in raw mode | Use `useApp().exit()` | +| No `isActive` with multiple `useInput` | All handlers fire | Coordinate with `isActive` | +| `focus('nonexistent')` | Silent no-op | Verify ID matches `useFocus({id})` | +| Assuming `key.shift` = Shift key for letters | Always true for uppercase | Check `input` case instead | +| `setRawMode(false)` directly on `stdin` | Breaks refcount | Use `useStdin().setRawMode` | +| `exit()` in effect cleanup | Re-entry risk | Schedule with `setTimeout(exit, 0)` | +| Not checking `isRawModeSupported` | Throws in CI | Guard with `if (isRawModeSupported)` | +| Multiple `autoFocus` | Only first wins | Use at most one `autoFocus` | +| Tab for custom navigation | Reserved by focus | `disableFocus()` first | diff --git a/.agents/skills/ink/rules/performance.md b/.agents/skills/ink/rules/performance.md new file mode 100644 index 0000000..3a68163 --- /dev/null +++ b/.agents/skills/ink/rules/performance.md @@ -0,0 +1,223 @@ +# Performance Best Practices + +## How the Render Loop Works + +Ink throttles renders at **30 FPS** by default (~33ms window). The throttle runs with `leading: true, trailing: true` — the first render fires immediately, then subsequent updates are batched into the trailing edge. + +Two render paths exist: +- **Normal render** → throttled at `maxFps` +- **Static render** (`` updates) → **always immediate**, bypasses throttle + +Output comparison (`output !== lastOutput`) prevents unnecessary terminal I/O when React re-renders produce no visible change. + +--- + +## Rule: Tune FPS to Your Use Case + +Default 30 FPS is a safe middle ground. Adjust based on your UI's needs: + +```jsx +// Heavy computation — avoid wasting CPU on renders +render(, {maxFps: 10}); + +// Real-time game or animation +render(, {maxFps: 60}); + +// Measure to find the bottleneck +render(, { + onRender: ({renderTime}) => { + if (renderTime > 16) console.error(`Slow render: ${renderTime}ms`); + }, +}); +``` + + + +--- + +## Rule: Use `` for Append-Only Output + +`` renders each item **once** and permanently. Previously rendered items never re-render, even as state changes around them. This is the primary tool for performance-sensitive, high-volume output (test runners, build logs, migrations). + +```jsx +// CORRECT +const TaskRunner = () => { + const [done, setDone] = useState([]); + const [current, setCurrent] = useState('Building...'); + + return ( + <> + + {task => ( + + ✔ {task.name} + + )} + + Running: {current} + + ); +}; +``` + +**Rules for ``:** +- Items array must only **grow** (append). Mutations to existing items are ignored. +- Each rendered element needs a **stable `key`** (use item ID, not array index if items can reorder). +- `` always renders immediately — Yoga calculates layout, then output fires before the throttle window. + + +--- + +## Rule: Never Use Unbounded Arrays for Live Display + +Arrays that grow without bound cause memory leaks and slow renders as React diffs the full array: + +```jsx +// BAD — grows forever, re-renders all items every tick +const [logs, setLogs] = useState([]); +useEffect(() => { + stream.on('data', line => setLogs(prev => [...prev, line])); +}, []); +return logs.map((log, i) => {log}); + +// GOOD — permanent output via Static + + {log => {log.text}} + + +// GOOD — bounded window for a scrollable display +const [recent, setRecent] = useState([]); +stream.on('data', line => setRecent(prev => [...prev.slice(-50), line])); +``` + +--- + +## Rule: Memoize Style Objects + +Every render, React passes props to the reconciler. If style is a new object each time, Ink diffs it against old props — Yoga then recalculates layout even if values are identical: + +```jsx +// BAD — new object every render → unnecessary Yoga layout recalc +const MyBox = () => ( + + ... + +); + +// GOOD — stable reference → reconciler diff returns null → Yoga skips +const style = {flexDirection: 'column', padding: 1}; +const MyBox = () => ...; + +// GOOD — memoize when props affect style +const MyBox = ({isWide}) => { + const style = useMemo( + () => ({flexDirection: isWide ? 'row' : 'column'}), + [isWide], + ); + return ...; +}; +``` + + +--- + +## Rule: Use Incremental Rendering for Sparse Updates + +When your UI has a large stable area and only a small region updates frequently, incremental rendering saves I/O by diffing line-by-line instead of erasing and redrawing everything: + +```jsx +render(, {incrementalRendering: true}); +``` + +**How it works:** Compares each line of new output against the previous frame. Unchanged lines → cursor moves past them. Changed lines → written. This avoids the ANSI erase-and-rewrite cycle for stable content. + + +--- + +## Rule: Memoize Expensive Context Values + +If you build a context provider, memoize its value to prevent all consumers from re-rendering on unrelated state changes: + +```jsx +// BAD — new object every render, all consumers re-render +const ctx = {data, refresh}; + +// GOOD — stable reference, consumers only re-render when data or refresh changes +const ctx = useMemo(() => ({data, refresh}), [data, refresh]); +``` + + +--- + +## Rule: Use `useCallback` for Stable Event Handler References + +Unstable handler references cause effects that depend on them to re-run unnecessarily: + +```jsx +// BAD — new function every render → effects re-run +const handleExit = (error) => { cleanup(); onExit(error); }; + +// GOOD — stable reference → effects only re-run when deps change +const handleExit = useCallback( + (error) => { cleanup(); onExit(error); }, + [cleanup, onExit], +); +``` + + +--- + +## Rule: Don't Update State at Unbounded Frequency + +Ink processes React updates synchronously by default (legacy mode). Firing state updates faster than `maxFps` doesn't produce more frames — it just wastes CPU: + +```jsx +// BAD — updates every millisecond, only 33ms renders fire +setInterval(() => setState(n => n + 1), 0); + +// GOOD — align update rate to render rate +setInterval(() => setState(n => n + 1), 1000 / 30); +``` + +--- + +## Rule: Profile with `onRender` + +Use the built-in metrics callback before optimizing. Don't guess: + +```jsx +const {rerender} = render(, { + onRender: ({renderTime}) => { + // renderTime = ms from start of render to completion + metrics.push(renderTime); + if (metrics.length % 100 === 0) { + const avg = metrics.reduce((a, b) => a + b, 0) / metrics.length; + process.stderr.write(`Avg render: ${avg.toFixed(2)}ms\n`); + } + }, +}); +``` +--- + +## Rule: Prefer `debug: true` During Development + +Debug mode disables throttling and writes each render separately without clearing: + +```jsx +render(, {debug: true}); +``` + +This lets you see every render frame in your terminal scrollback — useful for spotting unwanted re-renders. **Never use in production** as it accumulates all output. + +--- + +## Anti-Patterns Summary + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| Raw strings in `` | Throws at runtime | Wrap in `` | +| Inline style objects `{{padding: 1}}` | Yoga layout recalc every render | Hoist to module or `useMemo` | +| Unbounded `useState` arrays for logs | Memory leak + slow renders | Use `` | +| `setInterval` faster than `maxFps` | Wasted CPU | Match rate to FPS | +| `maxFps: 60` for slow data apps | Unnecessary renders | Lower to 10-15 | +| No `onRender` during perf debugging | Guessing | Add metrics first | diff --git a/.agents/skills/opentui/SKILL.md b/.agents/skills/opentui/SKILL.md new file mode 100644 index 0000000..ada49f5 --- /dev/null +++ b/.agents/skills/opentui/SKILL.md @@ -0,0 +1,200 @@ +--- +name: opentui +description: Comprehensive OpenTUI skill for building terminal user interfaces. Covers the core imperative API, React reconciler, and Solid reconciler. Use for any TUI development task including components, layout, keyboard handling, animations, and testing. +metadata: + references: core, react, solid +--- + +# OpenTUI Platform Skill + +Consolidated skill for building terminal user interfaces with OpenTUI. Use decision trees below to find the right framework and components, then load detailed references. + +## Critical Rules + +**Follow these rules in all OpenTUI code:** + +1. **Use `create-tui` for new projects.** See framework `REFERENCE.md` quick starts. +2. **`create-tui` options must come before arguments.** `bunx create-tui -t react my-app` works, `bunx create-tui my-app -t react` does NOT. +3. **Never call `process.exit()` directly.** Use `renderer.destroy()` (see `core/gotchas.md`). +4. **Text styling requires nested tags in React/Solid.** Use modifier elements, not props (see `components/text-display.md`). + +## How to Use This Skill + +### Reference File Structure + +Framework references follow a 5-file pattern. Cross-cutting concepts are single-file guides. + +Each framework in `./references//` contains: + +| File | Purpose | When to Read | +|------|---------|--------------| +| `REFERENCE.md` | Overview, when to use, quick start | **Always read first** | +| `api.md` | Runtime API, components, hooks | Writing code | +| `configuration.md` | Setup, tsconfig, bundling | Configuring a project | +| `patterns.md` | Common patterns, best practices | Implementation guidance | +| `gotchas.md` | Pitfalls, limitations, debugging | Troubleshooting | + +Cross-cutting concepts in `./references//` have `REFERENCE.md` as the entry point. + +### Reading Order + +1. Start with `REFERENCE.md` for your chosen framework +2. Then read additional files relevant to your task: + - Building components -> `api.md` + `components/.md` + - Setting up project -> `configuration.md` + - Layout/positioning -> `layout/REFERENCE.md` + - Keyboard/input handling -> `keyboard/REFERENCE.md` + - Animations -> `animation/REFERENCE.md` + - Troubleshooting -> `gotchas.md` + `testing/REFERENCE.md` + +### Example Paths + +``` +./references/react/REFERENCE.md # Start here for React +./references/react/api.md # React components and hooks +./references/solid/configuration.md # Solid project setup +./references/components/inputs.md # Input, Textarea, Select docs +./references/core/gotchas.md # Core debugging tips +``` + +### Runtime Notes + +OpenTUI runs on Bun and uses Zig for native builds. Read `./references/core/gotchas.md` for runtime requirements and build guidance. + +## Quick Decision Trees + +### "Which framework should I use?" + +``` +Which framework? +├─ I want full control, maximum performance, no framework overhead +│ └─ core/ (imperative API) +├─ I know React, want familiar component patterns +│ └─ react/ (React reconciler) +├─ I want fine-grained reactivity, optimal re-renders +│ └─ solid/ (Solid reconciler) +└─ I'm building a library/framework on top of OpenTUI + └─ core/ (imperative API) +``` + +### "I need to display content" + +``` +Display content? +├─ Plain or styled text -> components/text-display.md +├─ Container with borders/background -> components/containers.md +├─ Scrollable content area -> components/containers.md (scrollbox) +├─ ASCII art banner/title -> components/text-display.md (ascii-font) +├─ Data table with borders/wrapping -> components/code-diff.md (TextTable) +├─ Code with syntax highlighting -> components/code-diff.md +├─ Diff viewer (unified/split) -> components/code-diff.md +├─ Line numbers with diagnostics -> components/code-diff.md +└─ Markdown content (streaming) -> components/code-diff.md (markdown) +``` + +### "I need user input" + +``` +User input? +├─ Single-line text field -> components/inputs.md (input) +├─ Multi-line text editor -> components/inputs.md (textarea) +├─ Select from a list (vertical) -> components/inputs.md (select) +├─ Tab-based selection (horizontal) -> components/inputs.md (tab-select) +└─ Custom keyboard shortcuts -> keyboard/REFERENCE.md +``` + +### "I need layout/positioning" + +``` +Layout? +├─ Flexbox-style layouts (row, column, wrap) -> layout/REFERENCE.md +├─ Absolute positioning -> layout/patterns.md +├─ Responsive to terminal size -> layout/patterns.md +├─ Centering content -> layout/patterns.md +└─ Complex nested layouts -> layout/patterns.md +``` + +### "I need animations" + +``` +Animations? +├─ Timeline-based animations -> animation/REFERENCE.md +├─ Easing functions -> animation/REFERENCE.md +├─ Property transitions -> animation/REFERENCE.md +└─ Looping animations -> animation/REFERENCE.md +``` + +### "I need to handle input" + +``` +Input handling? +├─ Keyboard events (keypress, release) -> keyboard/REFERENCE.md +├─ Focus management -> keyboard/REFERENCE.md +├─ Paste events -> keyboard/REFERENCE.md +├─ Mouse events -> components/containers.md +├─ Text selection & copy-on-select -> keyboard/REFERENCE.md (selection) +└─ Clipboard (OSC 52) -> keyboard/REFERENCE.md (clipboard) +``` + +### "I need to test my TUI" + +``` +Testing? +├─ Snapshot testing -> testing/REFERENCE.md +├─ Interaction testing -> testing/REFERENCE.md +├─ Test renderer setup -> testing/REFERENCE.md +└─ Debugging tests -> testing/REFERENCE.md +``` + +### "I need to debug/troubleshoot" + +``` +Troubleshooting? +├─ Runtime errors, crashes -> /gotchas.md +├─ Layout issues -> layout/REFERENCE.md + layout/patterns.md +├─ Input/focus issues -> keyboard/REFERENCE.md +└─ Repro + regression tests -> testing/REFERENCE.md +``` + +### Troubleshooting Index + +- Terminal cleanup, crashes -> `core/gotchas.md` +- Text styling not applying -> `components/text-display.md` +- Input focus/shortcuts -> `keyboard/REFERENCE.md` +- Layout misalignment -> `layout/REFERENCE.md` +- Flaky snapshots -> `testing/REFERENCE.md` + +For component naming differences and text modifiers, see `components/REFERENCE.md`. + +## Product Index + +### Frameworks +| Framework | Entry File | Description | +|-----------|------------|-------------| +| Core | `./references/core/REFERENCE.md` | Imperative API, all primitives | +| React | `./references/react/REFERENCE.md` | React reconciler for declarative TUI | +| Solid | `./references/solid/REFERENCE.md` | SolidJS reconciler for declarative TUI | + +### Cross-Cutting Concepts +| Concept | Entry File | Description | +|---------|------------|-------------| +| Layout | `./references/layout/REFERENCE.md` | Yoga/Flexbox layout system | +| Components | `./references/components/REFERENCE.md` | Component reference by category | +| Keyboard | `./references/keyboard/REFERENCE.md` | Keyboard input handling | +| Animation | `./references/animation/REFERENCE.md` | Timeline-based animations | +| Testing | `./references/testing/REFERENCE.md` | Test renderer and snapshots | + +### Component Categories +| Category | Entry File | Components | +|----------|------------|------------| +| Text & Display | `./references/components/text-display.md` | text, ascii-font, styled text | +| Containers | `./references/components/containers.md` | box, scrollbox, borders | +| Inputs | `./references/components/inputs.md` | input, textarea, select, tab-select | +| Code & Diff | `./references/components/code-diff.md` | code, line-number, diff, markdown, text-table | + +## Resources + +**Repository**: https://github.com/anomalyco/opentui +**Core Docs**: https://github.com/anomalyco/opentui/tree/main/packages/core/docs +**Examples**: https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples +**Awesome List**: https://github.com/msmps/awesome-opentui diff --git a/.agents/skills/opentui/references/animation/REFERENCE.md b/.agents/skills/opentui/references/animation/REFERENCE.md new file mode 100644 index 0000000..26dd954 --- /dev/null +++ b/.agents/skills/opentui/references/animation/REFERENCE.md @@ -0,0 +1,431 @@ +# Animation System + +OpenTUI provides a timeline-based animation system for smooth property transitions. + +## Overview + +Animations in OpenTUI use: +- **Timeline**: Orchestrates multiple animations +- **Animation Engine**: Manages timelines and rendering +- **Easing Functions**: Control animation curves + +## When to Use + +Use this reference when you need timeline-driven animations, easing curves, or progressive transitions. + +## Basic Usage + +### React + +```tsx +import { useTimeline } from "@opentui/react" +import { useEffect, useState } from "react" + +function AnimatedBox() { + const [width, setWidth] = useState(0) + + const timeline = useTimeline({ + duration: 2000, + }) + + useEffect(() => { + timeline.add( + { width: 0 }, + { + width: 50, + duration: 2000, + ease: "easeOutQuad", + onUpdate: (anim) => { + setWidth(Math.round(anim.targets[0].width)) + }, + } + ) + }, []) + + return ( + + ) +} +``` + +### Solid + +```tsx +import { useTimeline } from "@opentui/solid" +import { createSignal, onMount } from "solid-js" + +function AnimatedBox() { + const [width, setWidth] = createSignal(0) + + const timeline = useTimeline({ + duration: 2000, + }) + + onMount(() => { + timeline.add( + { width: 0 }, + { + width: 50, + duration: 2000, + ease: "easeOutQuad", + onUpdate: (anim) => { + setWidth(Math.round(anim.targets[0].width)) + }, + } + ) + }) + + return ( + + ) +} +``` + +### Core + +```typescript +import { createCliRenderer, Timeline, engine } from "@opentui/core" + +const renderer = await createCliRenderer() +engine.attach(renderer) + +const timeline = new Timeline({ + duration: 2000, + autoplay: true, +}) + +timeline.add( + { x: 0 }, + { + x: 50, + duration: 2000, + ease: "easeOutQuad", + onUpdate: (anim) => { + box.setLeft(Math.round(anim.targets[0].x)) + }, + } +) + +engine.addTimeline(timeline) +``` + +## Timeline Options + +```typescript +const timeline = useTimeline({ + duration: 2000, // Total duration in ms + loop: false, // Loop the timeline + autoplay: true, // Start automatically + onComplete: () => {}, // Called when timeline completes + onPause: () => {}, // Called when timeline pauses +}) +``` + +## Timeline Methods + +```typescript +// Add animation +timeline.add(target, properties, startTime?) + +// Control playback +timeline.play() // Start/resume +timeline.pause() // Pause +timeline.restart() // Restart from beginning + +// State +timeline.progress // Current progress (0-1) +timeline.duration // Total duration +``` + +## Animation Properties + +```typescript +timeline.add( + { value: 0 }, // Target object with initial values + { + value: 100, // Final value + duration: 1000, // Animation duration in ms + ease: "linear", // Easing function + delay: 0, // Delay before starting + onUpdate: (anim) => { + // Called each frame + const current = anim.targets[0].value + }, + onComplete: () => { + // Called when this animation completes + }, + }, + 0 // Start time in timeline (optional) +) +``` + +## Easing Functions + +Available easing functions: + +### Linear + +| Name | Description | +|------|-------------| +| `linear` | Constant speed | + +### Quad (Power of 2) + +| Name | Description | +|------|-------------| +| `easeInQuad` | Slow start | +| `easeOutQuad` | Slow end | +| `easeInOutQuad` | Slow start and end | + +### Cubic (Power of 3) + +| Name | Description | +|------|-------------| +| `easeInCubic` | Slower start | +| `easeOutCubic` | Slower end | +| `easeInOutCubic` | Slower start and end | + +### Quart (Power of 4) + +| Name | Description | +|------|-------------| +| `easeInQuart` | Even slower start | +| `easeOutQuart` | Even slower end | +| `easeInOutQuart` | Even slower start and end | + +### Expo (Exponential) + +| Name | Description | +|------|-------------| +| `easeInExpo` | Exponential start | +| `easeOutExpo` | Exponential end | +| `easeInOutExpo` | Exponential start and end | + +### Back (Overshoot) + +| Name | Description | +|------|-------------| +| `easeInBack` | Pull back, then forward | +| `easeOutBack` | Overshoot, then settle | +| `easeInOutBack` | Both | + +### Elastic + +| Name | Description | +|------|-------------| +| `easeInElastic` | Elastic start | +| `easeOutElastic` | Elastic end (bouncy) | +| `easeInOutElastic` | Both | + +### Bounce + +| Name | Description | +|------|-------------| +| `easeInBounce` | Bounce at start | +| `easeOutBounce` | Bounce at end | +| `easeInOutBounce` | Both | + +## Patterns + +### Progress Bar + +```tsx +function ProgressBar({ progress }: { progress: number }) { + const [width, setWidth] = useState(0) + const maxWidth = 50 + + const timeline = useTimeline() + + useEffect(() => { + timeline.add( + { value: width }, + { + value: (progress / 100) * maxWidth, + duration: 300, + ease: "easeOutQuad", + onUpdate: (anim) => { + setWidth(Math.round(anim.targets[0].value)) + }, + } + ) + }, [progress]) + + return ( + + Progress: {progress}% + + + + + ) +} +``` + +### Fade In + +```tsx +function FadeIn({ children }) { + const [opacity, setOpacity] = useState(0) + + const timeline = useTimeline() + + useEffect(() => { + timeline.add( + { opacity: 0 }, + { + opacity: 1, + duration: 500, + ease: "easeOutQuad", + onUpdate: (anim) => { + setOpacity(anim.targets[0].opacity) + }, + } + ) + }, []) + + return ( + + {children} + + ) +} +``` + +### Looping Animation + +```tsx +function Spinner() { + const [frame, setFrame] = useState(0) + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + useEffect(() => { + const interval = setInterval(() => { + setFrame(f => (f + 1) % frames.length) + }, 80) + + return () => clearInterval(interval) + }, []) + + return {frames[frame]} Loading... +} +``` + +### Staggered Animation + +```tsx +function StaggeredList({ items }) { + const [visibleCount, setVisibleCount] = useState(0) + + useEffect(() => { + let count = 0 + const interval = setInterval(() => { + count++ + setVisibleCount(count) + if (count >= items.length) { + clearInterval(interval) + } + }, 100) + + return () => clearInterval(interval) + }, [items.length]) + + return ( + + {items.slice(0, visibleCount).map((item, i) => ( + {item} + ))} + + ) +} +``` + +### Slide In + +```tsx +function SlideIn({ children, from = "left" }) { + const [offset, setOffset] = useState(from === "left" ? -20 : 20) + + const timeline = useTimeline() + + useEffect(() => { + timeline.add( + { offset: from === "left" ? -20 : 20 }, + { + offset: 0, + duration: 300, + ease: "easeOutCubic", + onUpdate: (anim) => { + setOffset(Math.round(anim.targets[0].offset)) + }, + } + ) + }, []) + + return ( + + {children} + + ) +} +``` + +## Performance Tips + +### Batch Updates + +Timeline automatically batches updates within the render loop. + +### Use Integer Values + +Round animated values for character-based positioning: + +```typescript +onUpdate: (anim) => { + setX(Math.round(anim.targets[0].x)) +} +``` + +### Clean Up Timelines + +Hooks automatically clean up, but for core: + +```typescript +// When done with timeline +engine.removeTimeline(timeline) +``` + +## Gotchas + +### Terminal Refresh Rate + +Terminal UIs typically refresh at 60 FPS max. Very fast animations may appear choppy. + +### Character Grid + +Animations are constrained to character cells. Sub-pixel positioning isn't possible. + +### Cleanup in Effects + +Always clean up intervals and timelines: + +```tsx +useEffect(() => { + const interval = setInterval(...) + return () => clearInterval(interval) +}, []) +``` + +## See Also + +- [React API](../react/api.md) - `useTimeline` hook reference +- [Solid API](../solid/api.md) - `useTimeline` hook reference +- [Core API](../core/api.md) - `AnimationEngine` and `Timeline` classes +- [Layout Patterns](../layout/patterns.md) - Animated positioning and transitions diff --git a/.agents/skills/opentui/references/components/REFERENCE.md b/.agents/skills/opentui/references/components/REFERENCE.md new file mode 100644 index 0000000..a28ce8d --- /dev/null +++ b/.agents/skills/opentui/references/components/REFERENCE.md @@ -0,0 +1,144 @@ +# OpenTUI Components + +Reference for all OpenTUI components, organized by category. Components are available in all three frameworks (Core, React, Solid) with slight API differences. + +## When to Use + +Use this reference when you need to find the right component category or compare naming across Core, React, and Solid. + +## Component Categories + +| Category | Components | File | +|----------|------------|------| +| Text & Display | text, ascii-font, styled text | [text-display.md](./text-display.md) | +| Containers | box, scrollbox, borders | [containers.md](./containers.md) | +| Inputs | input, textarea, select, tab-select | [inputs.md](./inputs.md) | +| Code & Diff | code, line-number, diff, markdown, text-table | [code-diff.md](./code-diff.md) | + +## Component Chooser + +``` +Need a component? +├─ Styled text or ASCII art -> text-display.md +├─ Containers, borders, scrolling -> containers.md +├─ Forms or input controls -> inputs.md +└─ Code blocks, diffs, line numbers, markdown -> code-diff.md +``` + +## Component Naming + +Components have different names across frameworks: + +| Concept | Core (Class) | React (JSX) | Solid (JSX) | +|---------|--------------|-------------|-------------| +| Text | `TextRenderable` | `` | `` | +| Box | `BoxRenderable` | `` | `` | +| ScrollBox | `ScrollBoxRenderable` | `` | `` | +| Input | `InputRenderable` | `` | `` | +| Textarea | `TextareaRenderable` | `