mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7628640984 | ||
|
|
bf3a7ec61d | ||
|
|
453484985a | ||
|
|
d69a59ea9d | ||
|
|
c43b36e01c | ||
|
|
c538bb1604 | ||
|
|
e73146e622 | ||
|
|
f4d13b6612 | ||
|
|
b7993ed4ae |
@@ -22,7 +22,8 @@
|
||||
"@json-render/svelte",
|
||||
"@json-render/solid",
|
||||
"@json-render/react-three-fiber",
|
||||
"@json-render/yaml"
|
||||
"@json-render/yaml",
|
||||
"@json-render/ink"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -23,6 +23,8 @@ npm install @json-render/core @json-render/vue
|
||||
npm install @json-render/core @json-render/svelte
|
||||
# or for SolidJS
|
||||
npm install @json-render/core @json-render/solid
|
||||
# or for terminal UIs
|
||||
npm install @json-render/core @json-render/ink ink react
|
||||
# or for 3D scenes
|
||||
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
|
||||
```
|
||||
@@ -128,6 +130,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
||||
| `@json-render/ink` | Ink terminal renderer with built-in components for interactive TUIs. |
|
||||
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
|
||||
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
@@ -479,6 +482,47 @@ const { registry } = defineRegistry(catalog, {
|
||||
/>;
|
||||
```
|
||||
|
||||
### Ink (Terminal)
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import {
|
||||
schema,
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
defineRegistry,
|
||||
Renderer,
|
||||
JSONUIProvider,
|
||||
} from "@json-render/ink";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { ...standardComponentDefinitions },
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components: {} });
|
||||
|
||||
const spec = {
|
||||
root: "card-1",
|
||||
elements: {
|
||||
"card-1": {
|
||||
type: "Card",
|
||||
props: { title: "Status" },
|
||||
children: ["status-1"],
|
||||
},
|
||||
"status-1": {
|
||||
type: "StatusLine",
|
||||
props: { label: "Build", status: "success" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
<JSONUIProvider initialState={{}}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>;
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Streaming (SpecStream)
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# web
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/codegen@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/yaml@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/ink")
|
||||
|
||||
# @json-render/ink
|
||||
|
||||
Terminal renderer for [Ink](https://github.com/vadimdemedes/ink) with multiple standard components, providers, hooks, and streaming support.
|
||||
|
||||
## Installation
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/ink" />
|
||||
|
||||
Peer dependencies: `react ^18.0.0 || ^19.0.0`, `ink ^6.0.0`, and `zod ^4.0.0`.
|
||||
|
||||
<PackageInstall packages="react ink zod" />
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Layout
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Box</code></td><td><code>flexDirection</code>, <code>alignItems</code>, <code>justifyContent</code>, <code>gap</code>, <code>padding</code>, <code>margin</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>width</code>, <code>height</code>, <code>display</code>, <code>overflow</code></td><td>Flexbox layout container (like a terminal div)</td></tr>
|
||||
<tr><td><code>Spacer</code></td><td>(none)</td><td>Flexible empty space that expands to fill available room</td></tr>
|
||||
<tr><td><code>Newline</code></td><td><code>count</code></td><td>Insert blank lines</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Content
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Text</code></td><td><code>text</code>, <code>color</code>, <code>bold</code>, <code>italic</code>, <code>underline</code>, <code>strikethrough</code>, <code>dimColor</code>, <code>inverse</code>, <code>wrap</code></td><td>Text output with styling</td></tr>
|
||||
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code> (h1-h4), <code>color</code></td><td>Section heading</td></tr>
|
||||
<tr><td><code>Divider</code></td><td><code>character</code>, <code>color</code>, <code>dimColor</code>, <code>title</code>, <code>width</code></td><td>Horizontal separator line with optional title</td></tr>
|
||||
<tr><td><code>Badge</code></td><td><code>label</code>, <code>variant</code></td><td>Colored inline label (default, info, success, warning, error)</td></tr>
|
||||
<tr><td><code>Spinner</code></td><td><code>label</code>, <code>color</code></td><td>Animated loading spinner</td></tr>
|
||||
<tr><td><code>ProgressBar</code></td><td><code>progress</code> (0-1), <code>width</code>, <code>color</code>, <code>label</code></td><td>Horizontal progress bar</td></tr>
|
||||
<tr><td><code>StatusLine</code></td><td><code>text</code>, <code>status</code>, <code>icon</code></td><td>Status message with colored icon</td></tr>
|
||||
<tr><td><code>KeyValue</code></td><td><code>label</code>, <code>value</code>, <code>labelColor</code>, <code>separator</code></td><td>Key-value pair display</td></tr>
|
||||
<tr><td><code>Link</code></td><td><code>url</code>, <code>label</code>, <code>color</code></td><td>Renders a URL as underlined text. Shows "label (url)" when label is provided.</td></tr>
|
||||
<tr><td><code>Markdown</code></td><td><code>text</code></td><td>Renders markdown with terminal styling (headings, bold, italic, code, lists, blockquotes, horizontal rules)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Data
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Table</code></td><td><code>columns</code>, <code>rows</code>, <code>borderStyle</code>, <code>headerColor</code></td><td>Tabular data with headers</td></tr>
|
||||
<tr><td><code>List</code></td><td><code>items</code>, <code>ordered</code>, <code>bulletChar</code>, <code>spacing</code></td><td>Bulleted or numbered list</td></tr>
|
||||
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code></td><td>Structured list row</td></tr>
|
||||
<tr><td><code>Card</code></td><td><code>title</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>padding</code></td><td>Bordered container with optional title</td></tr>
|
||||
<tr><td><code>Sparkline</code></td><td><code>data</code>, <code>width</code>, <code>color</code>, <code>label</code>, <code>min</code>, <code>max</code></td><td>Inline sparkline chart using Unicode blocks (▁▂▃▄▅▆▇█)</td></tr>
|
||||
<tr><td><code>BarChart</code></td><td><code>data</code> (label/value/color), <code>width</code>, <code>showValues</code>, <code>showPercentage</code></td><td>Horizontal bar chart for comparing values</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Interactive
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>mask</code></td><td>Text input field. Press Enter to submit.</td></tr>
|
||||
<tr><td><code>Select</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code></td><td>Arrow-key selection menu</td></tr>
|
||||
<tr><td><code>MultiSelect</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>min</code>, <code>max</code></td><td>Multi-selection menu. Space to toggle, Enter to confirm.</td></tr>
|
||||
<tr><td><code>ConfirmInput</code></td><td><code>message</code>, <code>defaultValue</code>, <code>yesLabel</code>, <code>noLabel</code></td><td>Yes/No confirmation prompt. Press Y or N.</td></tr>
|
||||
<tr><td><code>Tabs</code></td><td><code>tabs</code>, <code>value</code> (use <code>$bindState</code>), <code>color</code></td><td>Tab bar navigation with left/right arrow keys. Place child content inside with visible conditions.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Providers
|
||||
|
||||
### JSONUIProvider
|
||||
|
||||
Convenience wrapper around all providers: `StateProvider` → `VisibilityProvider` → `ValidationProvider` → `ActionProvider` → `FocusProvider`.
|
||||
|
||||
```tsx
|
||||
import { JSONUIProvider, Renderer } from "@json-render/ink";
|
||||
|
||||
<JSONUIProvider initialState={{}} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object} onStateChange={fn}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
|
||||
<tr><td><code>initialState</code></td><td><code>Record<string, unknown></code></td><td>Initial state model (uncontrolled mode).</td></tr>
|
||||
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to bypass internal state and wire json-render to any state management:
|
||||
|
||||
```tsx
|
||||
import { createStateStore } from "@json-render/ink";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — components re-render automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
<ActionProvider handlers={Record<string, ActionHandler>} navigate={fn}>
|
||||
{children}
|
||||
</ActionProvider>
|
||||
```
|
||||
|
||||
Built-in actions: `setState`, `pushState`, `removeState`, `log`, `exit`. Custom handlers override built-ins. Includes a terminal confirmation dialog (press Y/N) for actions with `confirm`.
|
||||
|
||||
### VisibilityProvider
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
```
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
<ValidationProvider>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
```
|
||||
|
||||
### FocusProvider
|
||||
|
||||
```tsx
|
||||
<FocusProvider>
|
||||
{children}
|
||||
</FocusProvider>
|
||||
```
|
||||
|
||||
Manages Tab-cycling focus between interactive components (TextInput, Select). Supports `useFocusDisable` to suppress cycling during modal dialogs.
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry. Standard components are built-in; only register custom components.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry, type Components } from "@json-render/ink";
|
||||
|
||||
const { registry, handlers, executeAction } = defineRegistry(catalog, {
|
||||
components: {
|
||||
MyWidget: ({ props }) => <Text>{props.label}</Text>,
|
||||
} as Components<typeof catalog>,
|
||||
actions: {
|
||||
submit: async (params, setState, state) => {
|
||||
// custom action logic
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`handlers` is designed for `JSONUIProvider`/`ActionProvider`. `executeAction` is an imperative helper.
|
||||
|
||||
## createRenderer
|
||||
|
||||
Higher-level helper that wraps `Renderer` + all providers into a single component.
|
||||
|
||||
```tsx
|
||||
import { createRenderer } from "@json-render/ink";
|
||||
|
||||
const UIRenderer = createRenderer(catalog, components);
|
||||
|
||||
<UIRenderer spec={spec} state={initialState} />;
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useUIStream
|
||||
|
||||
```typescript
|
||||
const {
|
||||
spec, // Spec | null - current UI state
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
|
||||
stop, // () => void - abort the current stream
|
||||
clear, // () => void - reset spec and error
|
||||
} = useUIStream({
|
||||
api: string,
|
||||
onComplete?: (spec: Spec) => void,
|
||||
onError?: (error: Error) => void,
|
||||
fetch?: (url: string, init?: RequestInit) => Promise<Response>,
|
||||
validate?: boolean,
|
||||
maxRetries?: number,
|
||||
});
|
||||
```
|
||||
|
||||
### useStateStore
|
||||
|
||||
```typescript
|
||||
const { state, get, set, update } = useStateStore();
|
||||
```
|
||||
|
||||
### useStateValue
|
||||
|
||||
```typescript
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useBoundProp
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useBoundProp(resolvedValue, bindingPath);
|
||||
```
|
||||
|
||||
### useActions
|
||||
|
||||
```typescript
|
||||
const { execute } = useActions();
|
||||
```
|
||||
|
||||
### useIsVisible
|
||||
|
||||
```typescript
|
||||
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||
```
|
||||
|
||||
### useFocus
|
||||
|
||||
```typescript
|
||||
const { isActive, id } = useFocus();
|
||||
```
|
||||
|
||||
### useFocusDisable
|
||||
|
||||
```typescript
|
||||
useFocusDisable(disabled: boolean);
|
||||
```
|
||||
|
||||
Suppresses Tab-cycling while `disabled` is true (e.g., during a modal dialog).
|
||||
|
||||
## Catalog Exports
|
||||
|
||||
```typescript
|
||||
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/catalog";
|
||||
import { schema } from "@json-render/ink/schema";
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Export</th><th>Purpose</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 19 standard components</td></tr>
|
||||
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, pushState, removeState, log, exit)</td></tr>
|
||||
<tr><td><code>schema</code></td><td>Ink element tree schema</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Server Export
|
||||
|
||||
```typescript
|
||||
import { schema, standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/server";
|
||||
```
|
||||
|
||||
Re-exports the schema and catalog definitions for server-side usage (e.g., building system prompts).
|
||||
@@ -83,6 +83,13 @@ All renderers share the same workflow:
|
||||
</td>
|
||||
<td>Video compositions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ink</td>
|
||||
<td>
|
||||
<code>@json-render/ink</code>
|
||||
</td>
|
||||
<td>Terminal UI (via Ink)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -263,6 +270,30 @@ Uses a timeline spec format with compositions, tracks, and clips. Includes stand
|
||||
|
||||
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
|
||||
|
||||
## Ink (Terminal)
|
||||
|
||||
Render specs as terminal UIs using [Ink](https://github.com/vadimdemedes/ink). Multiple standard components including tables, progress bars, spinners, tabs, multi-select, and interactive inputs with Tab-cycling focus.
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/ink/schema";
|
||||
import {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
} from "@json-render/ink/catalog";
|
||||
import { defineRegistry, Renderer } from "@json-render/ink";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { ...standardComponentDefinitions },
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components: {} });
|
||||
<Renderer spec={spec} registry={registry} />;
|
||||
```
|
||||
|
||||
See the [@json-render/ink API reference](/docs/api/ink) for details.
|
||||
|
||||
## Custom Renderers
|
||||
|
||||
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
|
||||
|
||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, ink, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/image", href: "/docs/api/image" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/ink", href: "/docs/api/ink" },
|
||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
|
||||
{ title: "@json-render/solid", href: "/docs/api/solid" },
|
||||
|
||||
@@ -133,6 +133,14 @@ export const examples: Example[] = [
|
||||
githubPath: "examples/image",
|
||||
demoUrl: "https://image-demo.json-render.dev",
|
||||
},
|
||||
{
|
||||
slug: "ink-chat",
|
||||
title: "Ink Chat",
|
||||
description:
|
||||
"Terminal chat agent that streams rich json-render UIs using Ink and the AI Gateway.",
|
||||
tags: ["Ink", "Terminal", "AI"],
|
||||
githubPath: "examples/ink-chat",
|
||||
},
|
||||
{
|
||||
slug: "mcp",
|
||||
title: "MCP App",
|
||||
|
||||
@@ -59,6 +59,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/api/jotai": "@json-render/jotai API",
|
||||
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
|
||||
"docs/api/xstate": "@json-render/xstate API",
|
||||
"docs/api/ink": "@json-render/ink API",
|
||||
"docs/api/yaml": "@json-render/yaml API",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/shadcn@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/codegen@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# example-game-engine
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/react-three-fiber@0.15.0
|
||||
- @json-render/yaml@0.15.0
|
||||
@@ -31,7 +31,8 @@ export function CharacterInteraction() {
|
||||
if (!camera) return;
|
||||
|
||||
const cameraPos = new THREE.Vector3();
|
||||
camera.getWorldPosition(cameraPos);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
||||
camera.getWorldPosition(cameraPos as any);
|
||||
|
||||
const characters = objects.filter(
|
||||
(obj) => obj.type === "character" && obj.visible,
|
||||
|
||||
@@ -590,7 +590,8 @@ export function GameExtrude({
|
||||
>
|
||||
<extrudeGeometry
|
||||
args={[
|
||||
shape as THREE.Shape,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
||||
shape as any,
|
||||
{ depth: depth ?? 1, bevelEnabled: false },
|
||||
]}
|
||||
/>
|
||||
@@ -642,7 +643,8 @@ export function GameTube({
|
||||
>
|
||||
<tubeGeometry
|
||||
args={[
|
||||
curve as THREE.Curve<THREE.Vector3>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
||||
curve as any,
|
||||
tubularSegments ?? 64,
|
||||
radius ?? 0.1,
|
||||
radialSegments ?? 8,
|
||||
@@ -714,7 +716,8 @@ export function GameShape({
|
||||
castShadow={castShadow ?? false}
|
||||
receiveShadow={receiveShadow ?? false}
|
||||
>
|
||||
<shapeGeometry args={[shape as THREE.Shape]} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch */}
|
||||
<shapeGeometry args={[shape as any]} />
|
||||
<meshStandardMaterial
|
||||
{...buildMaterialProps(material)}
|
||||
side={THREE.DoubleSide}
|
||||
|
||||
@@ -81,7 +81,8 @@ export function MediaPlane({
|
||||
<planeGeometry args={[w, h]} />
|
||||
{texture ? (
|
||||
<meshBasicMaterial
|
||||
map={texture as THREE.Texture}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
||||
map={texture as any}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -50,8 +50,9 @@ function ModelInner({
|
||||
const model = scene.clone();
|
||||
|
||||
useEffect(() => {
|
||||
model.traverse((node) => {
|
||||
if ((node as THREE.Mesh).isMesh) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
|
||||
model.traverse((node: any) => {
|
||||
if (node.isMesh) {
|
||||
node.castShadow = true;
|
||||
node.receiveShadow = true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-game-engine",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-image
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/image@0.15.0
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-image",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Required for local development — get from https://vercel.com/ai-gateway
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional — override the default model (anthropic/claude-haiku-4.5)
|
||||
# AI_GATEWAY_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
@@ -0,0 +1,9 @@
|
||||
# example-ink-chat
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/ink@0.15.0
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "example-ink-chat",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx --env-file=.env src/index.tsx",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/ink": "workspace:*",
|
||||
"ai": "^6.0.33",
|
||||
"@ai-sdk/gateway": "^3.0.52",
|
||||
"ink": "^6.8.0",
|
||||
"react": "19.2.4",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.3",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import {
|
||||
createMixedStreamParser,
|
||||
createStateStore,
|
||||
applySpecPatch,
|
||||
type Spec,
|
||||
} from "@json-render/core";
|
||||
import { JSONUIProvider, Renderer, useFocusDisable } from "@json-render/ink";
|
||||
import { catalog } from "./catalog.js";
|
||||
import { tools } from "./tools.js";
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
// Component types stepped through one-at-a-time in the wizard.
|
||||
// Tabs are excluded — they're navigation, rendered inline with the full spec.
|
||||
const WIZARD_TYPES = new Set([
|
||||
"TextInput",
|
||||
"Select",
|
||||
"MultiSelect",
|
||||
"ConfirmInput",
|
||||
]);
|
||||
|
||||
// Interactive component types that need live keyboard input (wizard types + Tabs)
|
||||
const INTERACTIVE_TYPES = new Set([...WIZARD_TYPES, "Tabs"]);
|
||||
|
||||
/** Check if a spec contains any interactive components */
|
||||
function hasInteractiveElements(spec: Spec): boolean {
|
||||
return Object.values(spec.elements).some((el) =>
|
||||
INTERACTIVE_TYPES.has(el.type),
|
||||
);
|
||||
}
|
||||
|
||||
/** Collect an element and all its descendants from the spec tree */
|
||||
function collectSubtree(spec: Spec, rootKey: string): Spec["elements"] {
|
||||
const result: Spec["elements"] = {};
|
||||
const queue = [rootKey];
|
||||
while (queue.length > 0) {
|
||||
const key = queue.shift()!;
|
||||
const el = spec.elements[key];
|
||||
if (!el) continue;
|
||||
result[key] = el;
|
||||
if (el.children) queue.push(...el.children);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get event→action bindings that auto-advance the wizard for each component type */
|
||||
function getAdvanceEvents(
|
||||
type: string,
|
||||
): Record<string, Array<{ action: string }>> | null {
|
||||
switch (type) {
|
||||
case "Select":
|
||||
return { change: [{ action: "advance" }] };
|
||||
case "TextInput":
|
||||
case "MultiSelect":
|
||||
return { submit: [{ action: "advance" }] };
|
||||
case "ConfirmInput":
|
||||
return {
|
||||
confirm: [{ action: "advance" }],
|
||||
deny: [{ action: "advance" }],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Step-specific hint text */
|
||||
function getStepHint(type: string, isLast: boolean): string {
|
||||
const action = isLast ? "submit" : "continue";
|
||||
switch (type) {
|
||||
case "Select":
|
||||
return `Use arrow keys, Enter to ${action}`;
|
||||
case "MultiSelect":
|
||||
return `Space to toggle, Enter to ${action}`;
|
||||
case "TextInput":
|
||||
return `Type your answer, Enter to ${action}`;
|
||||
case "ConfirmInput":
|
||||
return `Press Y or N to ${action}`;
|
||||
default:
|
||||
return `Make your selection to ${action}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompt — handwritten design guidance + catalog documentation.
|
||||
// Follows the same pattern as examples/chat/lib/agent.ts: a rich
|
||||
// AGENT_INSTRUCTIONS string with catalog.prompt() appended at the end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGENT_INSTRUCTIONS = `You are a terminal assistant that renders polished, information-dense UIs. You call tools for real-time data, then build clean terminal dashboards.
|
||||
|
||||
WORKFLOW:
|
||||
1. Call the appropriate tools to gather real data. Use web_search for topics not covered by the specialized tools (get_weather, get_hacker_news, get_github_repo, get_crypto_price).
|
||||
2. While tools run, output a single short status line (e.g. "Looking up weather data..."). This is the ONLY text allowed outside the spec fence.
|
||||
3. After tools return, output ALL content inside a \`\`\`spec fence. Never write paragraphs of prose outside the fence.
|
||||
4. For simple text replies (greetings, clarifications), still use a \`\`\`spec with a Markdown component.
|
||||
|
||||
DESIGN PRINCIPLES:
|
||||
- HIERARCHY: Every response needs clear visual structure. Start with an h1 Heading for the topic. Use h2 Headings for subsections. Use Card to group related content into shaded areas — Cards render as subtle background fills, not bordered boxes.
|
||||
- LEAD WITH THE STORY: Open with a brief Markdown paragraph (2-3 sentences) that tells the user the key insight or takeaway. Don't just dump data — frame it.
|
||||
- SUMMARY METRICS: After the narrative, show 2-4 Metric components for the most important numbers. Metric displays a dim label, bold value, and optional colored trend (up=green, down=red). Group them in a horizontal Box (flexDirection: row, gap: 3) so they read like a dashboard header. Use KeyValue only for simple label:value pairs that don't need emphasis.
|
||||
- DETAIL SECTIONS: Below the summary, use h2 Headings to introduce each section, followed by a single focused visualization (Table, BarChart, or set of KeyValues).
|
||||
- ONE REPRESENTATION PER DATA POINT: Never show the same value as both a number and a percentage and a bar. Pick the most meaningful format. Use BarChart with showValues:true OR showPercentage:true, not both.
|
||||
- TABLES: Always set explicit column widths so columns don't collapse. Use headerColor:"cyan". Keep column headers short (abbreviate if needed). Right-align numeric columns.
|
||||
- CHARTS: Use distinct colors per bar in BarChart. Good palette: cyan, green, yellow, magenta, blue, red. Use Sparkline for compact inline trends alongside other content.
|
||||
- COLOR STRATEGY: Use color with intention, not decoration. cyan for labels and headers. green for positive values, growth, success. red for negative values, decline, errors. yellow for warnings or neutral highlights. dimColor:true for secondary/supporting text. Avoid coloring everything — contrast comes from restraint.
|
||||
- TABLES: Use borderStyle:"single" on Tables for a clean outline. Do NOT put Tables inside Cards — Tables have their own border and don't need additional wrapping.
|
||||
- SPACING: Use gap:1 between sections. Don't over-pad. Keep the UI compact and scannable. NEVER add padding to the root element — the app already provides outer padding.
|
||||
- WIDTH: Target 80 columns. Set explicit widths on Tables (total columns should sum to ~70-75). Use wrap:"truncate-end" on Text in tight spaces.
|
||||
- CALLOUTS: Use Callout for key takeaways, important notes, tips, and warnings. Set type (info/tip/warning/important) for a colored left border accent. Keep content concise — one key point per Callout.
|
||||
- TIMELINES: Use Timeline for historical events, step-by-step processes, and milestones. Set status per item (completed/current/upcoming) for colored dots. Include dates when available.
|
||||
- NEVER use emojis anywhere — not in text, labels, titles, table cells, Heading text, or component props. Plain text only.
|
||||
|
||||
DASHBOARD PATTERN (use for data-heavy responses):
|
||||
Root Box (column, gap:1) >
|
||||
Heading (h1, topic title)
|
||||
Markdown (2-3 sentence summary with key takeaway)
|
||||
Box (row, gap:3) > [Metric, Metric, Metric] (top-line metrics, no Card)
|
||||
Heading (h2, section title)
|
||||
Table (borderStyle:"single")
|
||||
Card (title:"Section Name") > BarChart (bar charts go in a titled Card — the Card title replaces h2)
|
||||
Callout (type:"tip", key takeaway or closing note)
|
||||
Card wrapping rules: Wrap BarCharts in a Card with a title. Do NOT wrap Metrics or Tables in Cards — Metrics stand alone, Tables have their own border.
|
||||
|
||||
COMPARISON PATTERN:
|
||||
Use BarChart when you want the user to see relative magnitudes at a glance.
|
||||
Use Table when there are 3+ columns of mixed data types.
|
||||
Never use both for the same data.
|
||||
|
||||
TREND PATTERN:
|
||||
Use Sparkline for compact inline trend next to a KeyValue.
|
||||
Use BarChart with year/period labels for detailed time-series.
|
||||
|
||||
INTERACTIVITY:
|
||||
- You can create interactive forms, surveys, and selection interfaces. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.
|
||||
- ALWAYS include a submit action on interactive UIs. Add a Text or StatusLine telling the user how to submit. Wire submit events to a "submit" action — the app collects form state automatically.
|
||||
- ALWAYS populate the state field with sensible defaults for all bound values.
|
||||
- Use $bindState on interactive components for two-way binding. Example: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.
|
||||
- Use Tabs for multi-section surveys. Use ConfirmInput for yes/no prompts.
|
||||
- After receiving form data, acknowledge the user's choices meaningfully — don't just echo them back.
|
||||
|
||||
${catalog.prompt({
|
||||
mode: "inline",
|
||||
customRules: [
|
||||
"ALL text MUST go inside the spec using the Markdown component. The ONLY text outside the fence is a short tool-status line.",
|
||||
"For text-only answers, still output a spec with a Markdown component.",
|
||||
"Prefer Table for structured data and KeyValue for label-value pairs.",
|
||||
"NEVER use emojis anywhere in your output. Plain text only.",
|
||||
],
|
||||
})}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
spec: Spec | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatInput — simple terminal text input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChatInput({
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.return && value.trim()) {
|
||||
onSubmit(value.trim());
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
setValue((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl || key.meta || key.escape || key.tab) return;
|
||||
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
|
||||
return;
|
||||
|
||||
if (input) {
|
||||
setValue((prev) => prev + input);
|
||||
}
|
||||
},
|
||||
{ isActive: !disabled },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text bold>{"› "}</Text>
|
||||
{value ? (
|
||||
<Text>{value}</Text>
|
||||
) : (
|
||||
<Text dimColor>{disabled ? "Thinking..." : "Type a message..."}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
function AnimatedSpinner({
|
||||
label,
|
||||
color = "cyan",
|
||||
}: {
|
||||
label: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
|
||||
}, 80);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text color={color}>{SPINNER_FRAMES[frame]}</Text>
|
||||
<Text dimColor>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Suppress Tab-cycling inside read-only message providers so old interactive
|
||||
* components (e.g. Tabs) can't steal focus/arrow-key input. */
|
||||
function DisableFocus() {
|
||||
useFocusDisable(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Render markdown text through the standard Renderer pipeline (uses the
|
||||
* built-in Markdown component without exporting MarkdownText). */
|
||||
function RenderedMarkdown({ text }: { text: string }) {
|
||||
const spec: Spec = useMemo(
|
||||
() => ({
|
||||
root: "md",
|
||||
elements: { md: { type: "Markdown", props: { text }, children: [] } },
|
||||
}),
|
||||
[text],
|
||||
);
|
||||
|
||||
return (
|
||||
<JSONUIProvider initialState={{}}>
|
||||
<DisableFocus />
|
||||
<Renderer spec={spec} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageView({ message }: { message: Message }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>You: </Text>
|
||||
<Text>{message.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{message.spec ? (
|
||||
<JSONUIProvider initialState={message.spec.state ?? {}}>
|
||||
<DisableFocus />
|
||||
<Renderer spec={message.spec} />
|
||||
</JSONUIProvider>
|
||||
) : message.text ? (
|
||||
<RenderedMarkdown text={message.text} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveInteractiveSpec — wizard that shows one interactive element at a time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LiveInteractiveSpec({
|
||||
spec,
|
||||
onSubmit,
|
||||
}: {
|
||||
spec: Spec;
|
||||
onSubmit: (state: Record<string, unknown>) => void;
|
||||
}) {
|
||||
// Extract wizard-steppable element keys (Tabs are excluded)
|
||||
const interactiveKeys = useMemo(
|
||||
() =>
|
||||
Object.entries(spec.elements)
|
||||
.filter(([_, el]) => WIZARD_TYPES.has(el.type))
|
||||
.map(([key]) => key),
|
||||
[spec],
|
||||
);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const store = useMemo(() => createStateStore(spec.state ?? {}), [spec]);
|
||||
// Guard against double-advance from key repeats (e.g. holding Y on ConfirmInput)
|
||||
const advancingRef = useRef(false);
|
||||
|
||||
const currentKey = interactiveKeys[step];
|
||||
const currentElement = currentKey ? spec.elements[currentKey] : null;
|
||||
const isLast = step >= interactiveKeys.length - 1;
|
||||
|
||||
// Reset the guard when the step changes
|
||||
useEffect(() => {
|
||||
advancingRef.current = false;
|
||||
}, [step]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (advancingRef.current) return;
|
||||
advancingRef.current = true;
|
||||
if (isLast) {
|
||||
onSubmit(store.getSnapshot());
|
||||
} else {
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
}, [isLast, onSubmit, store]);
|
||||
|
||||
// Build a minimal spec containing only the current interactive element
|
||||
const stepSpec = useMemo<Spec | null>(() => {
|
||||
if (!currentKey || !currentElement) return null;
|
||||
|
||||
const elements = collectSubtree(spec, currentKey);
|
||||
|
||||
// Wire auto-advance events
|
||||
const advanceEvents = getAdvanceEvents(currentElement.type);
|
||||
if (advanceEvents) {
|
||||
elements[currentKey] = {
|
||||
...elements[currentKey]!,
|
||||
on: { ...(elements[currentKey] as any).on, ...advanceEvents },
|
||||
};
|
||||
}
|
||||
|
||||
return { root: currentKey, elements, state: spec.state };
|
||||
}, [currentKey, currentElement, spec]);
|
||||
|
||||
const handlers = useMemo(() => ({ submit: advance, advance }), [advance]);
|
||||
|
||||
// No wizard-steppable elements (e.g. Tabs-only spec) → render the full spec
|
||||
if (interactiveKeys.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<JSONUIProvider store={store} handlers={handlers}>
|
||||
<Renderer spec={spec} />
|
||||
</JSONUIProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stepSpec || !currentElement) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{interactiveKeys.length > 1 && (
|
||||
<Text dimColor>
|
||||
Step {step + 1} of {interactiveKeys.length}
|
||||
</Text>
|
||||
)}
|
||||
<JSONUIProvider store={store} handlers={handlers}>
|
||||
<Renderer spec={stepSpec} />
|
||||
</JSONUIProvider>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
{getStepHint(currentElement.type, isLast)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App — main chat loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function App() {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingStatus, setStreamingStatus] = useState("Thinking...");
|
||||
const [streamingSpec, setStreamingSpec] = useState<Spec | null>(null);
|
||||
const nextMessageIdRef = useRef(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Ref tracks latest messages so sendMessage doesn't need it as a dep
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// Track a live interactive spec awaiting user input
|
||||
const [liveSpec, setLiveSpec] = useState<Spec | null>(null);
|
||||
|
||||
// Ctrl+C to exit (suppress Escape when interactive spec is live)
|
||||
useInput((_input, key) => {
|
||||
if (key.ctrl && _input === "c") {
|
||||
abortRef.current?.abort();
|
||||
exit();
|
||||
}
|
||||
if (key.escape && !liveSpec) {
|
||||
abortRef.current?.abort();
|
||||
exit();
|
||||
}
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
abortRef.current?.abort();
|
||||
// Clear any live interactive spec
|
||||
setLiveSpec(null);
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
id: nextMessageIdRef.current++,
|
||||
role: "user",
|
||||
text,
|
||||
spec: null,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsStreaming(true);
|
||||
setStreamingStatus("Thinking...");
|
||||
|
||||
// Build conversation history from ref (avoids stale closure).
|
||||
// For assistant messages with specs, serialize the spec so the model
|
||||
// remembers what it rendered in previous turns.
|
||||
const history = [
|
||||
...messagesRef.current.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.spec
|
||||
? `${m.text}\n\`\`\`spec\n${JSON.stringify(m.spec)}\n\`\`\``
|
||||
: m.text,
|
||||
})),
|
||||
{ role: "user" as const, content: text },
|
||||
];
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
|
||||
system: AGENT_INSTRUCTIONS,
|
||||
messages: history,
|
||||
temperature: 0.7,
|
||||
abortSignal: controller.signal,
|
||||
tools,
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
let conversationText = "";
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
let hasSpec = false;
|
||||
|
||||
const parser = createMixedStreamParser({
|
||||
onText: (chunk) => {
|
||||
conversationText += chunk + "\n";
|
||||
},
|
||||
onPatch: (patch) => {
|
||||
hasSpec = true;
|
||||
spec = applySpecPatch(structuredClone(spec), patch);
|
||||
setStreamingSpec(structuredClone(spec));
|
||||
},
|
||||
});
|
||||
|
||||
let hadTextInStep = false;
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "tool-call") {
|
||||
const name = part.toolName.replace(/_/g, " ");
|
||||
setStreamingStatus(`Using ${name}...`);
|
||||
} else if (part.type === "tool-result") {
|
||||
setStreamingStatus("Generating...");
|
||||
} else if (part.type === "text-start") {
|
||||
hadTextInStep = false;
|
||||
} else if (part.type === "text-delta") {
|
||||
hadTextInStep = true;
|
||||
parser.push(part.text);
|
||||
} else if (part.type === "text-end") {
|
||||
// Insert a paragraph break between text segments so text from
|
||||
// before/after tool calls doesn't merge into a wall.
|
||||
// Injected directly into conversationText (not through the
|
||||
// parser, which may drop empty lines in older builds).
|
||||
if (hadTextInStep) {
|
||||
parser.flush();
|
||||
conversationText += "\n\n";
|
||||
hadTextInStep = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
parser.flush();
|
||||
|
||||
// Finalize: add assistant message
|
||||
const finalSpec = hasSpec ? spec : null;
|
||||
const isInteractive = finalSpec && hasInteractiveElements(finalSpec);
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: nextMessageIdRef.current++,
|
||||
role: "assistant",
|
||||
text: conversationText.trim(),
|
||||
// If interactive, don't store spec in message history (it'll be live)
|
||||
spec: isInteractive ? null : finalSpec,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// If the spec has interactive components, keep it live
|
||||
if (isInteractive && finalSpec) {
|
||||
setLiveSpec(finalSpec);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
const errorMsg: Message = {
|
||||
id: nextMessageIdRef.current++,
|
||||
role: "assistant",
|
||||
text: `Error: ${(err as Error).message}`,
|
||||
spec: null,
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingSpec(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle interactive spec submission — collect state and send back to AI
|
||||
const handleInteractiveSubmit = useCallback(
|
||||
(state: Record<string, unknown>) => {
|
||||
// Freeze the spec into message history as a non-interactive snapshot
|
||||
if (liveSpec) {
|
||||
// Update the last assistant message to include the spec with submitted state
|
||||
const frozenSpec = { ...liveSpec, state };
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
// Find the last assistant message (which has spec: null for interactive)
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
if (updated[i]!.role === "assistant" && !updated[i]!.spec) {
|
||||
updated[i] = { ...updated[i]!, spec: frozenSpec };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setLiveSpec(null);
|
||||
}
|
||||
|
||||
// Format the submitted state as a user message and send to AI
|
||||
const formattedState = Object.entries(state)
|
||||
.map(([key, value]) => {
|
||||
if (Array.isArray(value)) return `${key}: ${value.join(", ")}`;
|
||||
return `${key}: ${value}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
sendMessage(`[Form submitted]\n${formattedState}`);
|
||||
},
|
||||
[liveSpec, sendMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} minHeight={stdout.rows}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1} gap={1}>
|
||||
<Text bold color="cyan">
|
||||
json-render
|
||||
</Text>
|
||||
<Text dimColor>Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
|
||||
{/* Empty state — show example prompts when no conversation yet */}
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text dimColor>Try asking:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1} gap={0}>
|
||||
<Text dimColor>{" weather in tokyo"}</Text>
|
||||
<Text dimColor>{" top hacker news stories"}</Text>
|
||||
<Text dimColor>{" tell me about vercel/next.js"}</Text>
|
||||
<Text dimColor>{" bitcoin price"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Message history — collapsed when interactive wizard is active */}
|
||||
{liveSpec && !isStreaming ? (
|
||||
<>
|
||||
{messages.length > 1 && (
|
||||
<Text dimColor italic>
|
||||
{messages.length - 1} earlier message
|
||||
{messages.length > 2 ? "s" : ""} hidden
|
||||
</Text>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<MessageView message={messages[messages.length - 1]!} />
|
||||
)}
|
||||
<LiveInteractiveSpec
|
||||
spec={liveSpec}
|
||||
onSubmit={handleInteractiveSubmit}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg) => (
|
||||
<MessageView key={msg.id} message={msg} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live spec preview while streaming */}
|
||||
{isStreaming && streamingSpec && streamingSpec.root && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<JSONUIProvider initialState={streamingSpec.state ?? {}}>
|
||||
<DisableFocus />
|
||||
<Renderer spec={streamingSpec} loading />
|
||||
</JSONUIProvider>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Spacer pushes input to bottom when content is short */}
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{/* Input — spinner replaces input while streaming, hidden during wizard */}
|
||||
{!liveSpec && (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
{isStreaming ? (
|
||||
<AnimatedSpinner label={streamingStatus} />
|
||||
) : (
|
||||
<ChatInput onSubmit={sendMessage} disabled={false} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/ink/schema";
|
||||
import {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
} from "@json-render/ink/catalog";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { render } from "ink";
|
||||
import { App } from "./app.js";
|
||||
|
||||
// Clear terminal and move cursor to top-left
|
||||
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
||||
|
||||
render(<App />, { exitOnCtrlC: false });
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Tools for the ink-chat example.
|
||||
*
|
||||
* All tools use free, public APIs — no API keys required beyond the
|
||||
* AI Gateway key that is already configured.
|
||||
*
|
||||
* These match the tools in examples/chat for consistency across examples.
|
||||
*/
|
||||
|
||||
import { tool, generateText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Web Search (Perplexity Sonar via AI Gateway)
|
||||
// =============================================================================
|
||||
|
||||
export const webSearch = tool({
|
||||
description:
|
||||
"Search the web for current information on any topic. Returns a synthesized answer based on real-time web data.",
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"The search query — be specific and include relevant context for better results",
|
||||
),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: gateway("perplexity/sonar"),
|
||||
prompt: query,
|
||||
});
|
||||
return { content: text };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Weather (Open-Meteo — free, no API key)
|
||||
// =============================================================================
|
||||
|
||||
function describeWeatherCode(code: number): string {
|
||||
const descriptions: Record<number, string> = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Foggy",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
71: "Slight snow",
|
||||
73: "Moderate snow",
|
||||
75: "Heavy snow",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
95: "Thunderstorm",
|
||||
};
|
||||
return descriptions[code] ?? "Unknown";
|
||||
}
|
||||
|
||||
export const getWeather = tool({
|
||||
description:
|
||||
"Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
|
||||
inputSchema: z.object({
|
||||
city: z
|
||||
.string()
|
||||
.describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
|
||||
}),
|
||||
execute: async ({ city }) => {
|
||||
try {
|
||||
const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
|
||||
const geocodeRes = await fetch(geocodeUrl);
|
||||
|
||||
if (!geocodeRes.ok) {
|
||||
return { error: `Failed to geocode city: ${city}` };
|
||||
}
|
||||
|
||||
const geocodeData = (await geocodeRes.json()) as {
|
||||
results?: Array<{
|
||||
name: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!geocodeData.results || geocodeData.results.length === 0) {
|
||||
return { error: `City not found: ${city}` };
|
||||
}
|
||||
|
||||
const location = geocodeData.results[0]!;
|
||||
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
|
||||
|
||||
const weatherRes = await fetch(weatherUrl);
|
||||
|
||||
if (!weatherRes.ok) {
|
||||
return { error: "Failed to fetch weather data" };
|
||||
}
|
||||
|
||||
const weather = (await weatherRes.json()) as {
|
||||
current: {
|
||||
temperature_2m: number;
|
||||
relative_humidity_2m: number;
|
||||
apparent_temperature: number;
|
||||
weather_code: number;
|
||||
wind_speed_10m: number;
|
||||
};
|
||||
daily: {
|
||||
time: string[];
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
precipitation_sum: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const forecast = weather.daily.time.map((date, i) => ({
|
||||
date,
|
||||
day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
}),
|
||||
high: Math.round(weather.daily.temperature_2m_max[i]!),
|
||||
low: Math.round(weather.daily.temperature_2m_min[i]!),
|
||||
condition: describeWeatherCode(weather.daily.weather_code[i]!),
|
||||
precipitation: weather.daily.precipitation_sum[i]!,
|
||||
}));
|
||||
|
||||
return {
|
||||
city: location.name,
|
||||
country: location.country,
|
||||
current: {
|
||||
temperature: Math.round(weather.current.temperature_2m),
|
||||
feelsLike: Math.round(weather.current.apparent_temperature),
|
||||
humidity: weather.current.relative_humidity_2m,
|
||||
windSpeed: Math.round(weather.current.wind_speed_10m),
|
||||
condition: describeWeatherCode(weather.current.weather_code),
|
||||
},
|
||||
forecast,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Weather fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Hacker News (Firebase API — free, no API key)
|
||||
// =============================================================================
|
||||
|
||||
export const getHackerNewsTop = tool({
|
||||
description:
|
||||
"Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
|
||||
inputSchema: z.object({
|
||||
count: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.describe("Number of top stories to fetch (1-30)"),
|
||||
}),
|
||||
execute: async ({ count }) => {
|
||||
try {
|
||||
const topRes = await fetch(
|
||||
"https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty",
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
|
||||
if (!topRes.ok) {
|
||||
return { error: "Failed to fetch Hacker News top stories" };
|
||||
}
|
||||
|
||||
const topIds = (await topRes.json()) as number[];
|
||||
const storyIds = topIds.slice(0, count);
|
||||
|
||||
const stories = await Promise.all(
|
||||
storyIds.map(async (id) => {
|
||||
const storyRes = await fetch(
|
||||
`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
if (!storyRes.ok) return null;
|
||||
|
||||
const story = (await storyRes.json()) as {
|
||||
id: number;
|
||||
title: string;
|
||||
url?: string;
|
||||
score: number;
|
||||
by: string;
|
||||
time: number;
|
||||
descendants?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
title: story.title,
|
||||
url:
|
||||
story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
|
||||
score: story.score,
|
||||
author: story.by,
|
||||
comments: story.descendants ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
stories: stories.filter(Boolean),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Hacker News fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// GitHub (Public API — free, no API key, 60 req/hr)
|
||||
// =============================================================================
|
||||
|
||||
const ghHeaders = { Accept: "application/vnd.github.v3+json" };
|
||||
|
||||
export const getGitHubRepo = tool({
|
||||
description:
|
||||
"Get information about a public GitHub repository including stars, forks, open issues, description, and language breakdown.",
|
||||
inputSchema: z.object({
|
||||
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
|
||||
repo: z.string().describe("Repository name (e.g., 'next.js')"),
|
||||
}),
|
||||
execute: async ({ owner, repo }) => {
|
||||
try {
|
||||
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
|
||||
const [repoRes, languagesRes] = await Promise.all([
|
||||
fetch(repoUrl, { headers: ghHeaders }),
|
||||
fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
|
||||
]);
|
||||
|
||||
if (!repoRes.ok) {
|
||||
if (repoRes.status === 404)
|
||||
return { error: `Not found: ${owner}/${repo}` };
|
||||
return { error: `Failed to fetch repo: ${repoRes.statusText}` };
|
||||
}
|
||||
|
||||
const repoData = (await repoRes.json()) as {
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
open_issues_count: number;
|
||||
language: string | null;
|
||||
license: { spdx_id: string } | null;
|
||||
topics: string[];
|
||||
};
|
||||
|
||||
const languages: Record<string, number> = languagesRes.ok
|
||||
? ((await languagesRes.json()) as Record<string, number>)
|
||||
: {};
|
||||
|
||||
const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
|
||||
const languageBreakdown = Object.entries(languages)
|
||||
.map(([lang, bytes]) => ({
|
||||
language: lang,
|
||||
percentage:
|
||||
totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage)
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
name: repoData.full_name,
|
||||
description: repoData.description,
|
||||
url: repoData.html_url,
|
||||
stars: repoData.stargazers_count,
|
||||
forks: repoData.forks_count,
|
||||
openIssues: repoData.open_issues_count,
|
||||
primaryLanguage: repoData.language,
|
||||
license: repoData.license?.spdx_id ?? "None",
|
||||
topics: repoData.topics,
|
||||
languages: languageBreakdown,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `GitHub fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Crypto (CoinGecko — free, no API key)
|
||||
// =============================================================================
|
||||
|
||||
export const getCryptoPrice = tool({
|
||||
description:
|
||||
"Get current price, market cap, 24h change, and 7-day trend for a cryptocurrency.",
|
||||
inputSchema: z.object({
|
||||
coinId: z
|
||||
.string()
|
||||
.describe(
|
||||
"CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ coinId }) => {
|
||||
try {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=false`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404)
|
||||
return { error: `Cryptocurrency not found: ${coinId}` };
|
||||
if (res.status === 429)
|
||||
return {
|
||||
error: "CoinGecko rate limit exceeded. Try again in a minute.",
|
||||
};
|
||||
return { error: `Failed to fetch crypto data: ${res.statusText}` };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
market_data: {
|
||||
current_price: { usd: number };
|
||||
market_cap: { usd: number };
|
||||
total_volume: { usd: number };
|
||||
price_change_percentage_24h: number;
|
||||
price_change_percentage_7d: number;
|
||||
high_24h: { usd: number };
|
||||
low_24h: { usd: number };
|
||||
};
|
||||
market_cap_rank: number;
|
||||
};
|
||||
|
||||
const md = data.market_data;
|
||||
|
||||
return {
|
||||
symbol: data.symbol.toUpperCase(),
|
||||
name: data.name,
|
||||
rank: data.market_cap_rank,
|
||||
price: md.current_price.usd,
|
||||
marketCap: md.market_cap.usd,
|
||||
volume24h: md.total_volume.usd,
|
||||
change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
|
||||
change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
|
||||
high24h: md.high_24h.usd,
|
||||
low24h: md.low_24h.usd,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Crypto fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// All tools (exported as a single record for streamText)
|
||||
// =============================================================================
|
||||
|
||||
export const tools = {
|
||||
web_search: webSearch,
|
||||
get_weather: getWeather,
|
||||
get_hacker_news: getHackerNewsTop,
|
||||
get_github_repo: getGitHubRepo,
|
||||
get_crypto_price: getCryptoPrice,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
# example-mcp
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/mcp@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/shadcn@0.15.0
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-mcp",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/shadcn@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-email
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react-email@0.15.0
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-email",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react-native@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react-pdf@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-react-three-fiber
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/react-three-fiber@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-three-fiber",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-remotion
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/remotion@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@json-render/core", "@json-render/remotion"],
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
// Deduplicate remotion — pnpm creates separate copies when peer
|
||||
// dependency versions (React) differ between workspace packages.
|
||||
// Force all imports to resolve from the app's node_modules.
|
||||
remotion: "./node_modules/remotion",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-solid
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/solid@0.15.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-solid",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-demo
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-fullpage-demo
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# svelte-chat
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/svelte@0.15.0
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "svelte-chat",
|
||||
"private": true,
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-svelte
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/svelte@0.15.0
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-svelte",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# vite-renderers
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
- @json-render/solid@0.15.0
|
||||
- @json-render/svelte@0.15.0
|
||||
- @json-render/vue@0.15.0
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vite-renderers",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-vue
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/vue@0.15.0
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-vue",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bf3a7ec: Add Ink terminal renderer for interactive terminal UIs.
|
||||
|
||||
### New:
|
||||
- **`@json-render/ink`** -- Terminal UI renderer for json-render, built on Ink. Includes 20+ standard components (Box, Text, Heading, Card, Table, TextInput, Select, MultiSelect, Tabs, etc.), action/validation/focus contexts, two-way state binding, and streaming via `useUIStream`. Server-safe entry points at `@json-render/ink/schema`, `@json-render/ink/catalog`, and `@json-render/ink/server`.
|
||||
|
||||
### Improved:
|
||||
- **Examples** -- new `ink-chat` terminal chat demo, `game-engine` 3D example using react-three-fiber, website examples page with live demos and search.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/image
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/image",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# @json-render/ink
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bf3a7ec: Add Ink terminal renderer for interactive terminal UIs.
|
||||
|
||||
### New:
|
||||
- **`@json-render/ink`** -- Terminal UI renderer for json-render, built on Ink. Includes 20+ standard components (Box, Text, Heading, Card, Table, TextInput, Select, MultiSelect, Tabs, etc.), action/validation/focus contexts, two-way state binding, and streaming via `useUIStream`. Server-safe entry points at `@json-render/ink/schema`, `@json-render/ink/catalog`, and `@json-render/ink/server`.
|
||||
|
||||
### Improved:
|
||||
- **Examples** -- new `ink-chat` terminal chat demo, `game-engine` 3D example using react-three-fiber, website examples page with live demos and search.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
@@ -0,0 +1,139 @@
|
||||
# @json-render/ink
|
||||
|
||||
Ink terminal renderer for json-render. Turn JSON specs into interactive terminal UIs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/ink @json-render/core ink react
|
||||
```
|
||||
|
||||
Peer dependencies: `ink ^6.0.0` and `react ^19.0.0`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Catalog
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/ink/schema";
|
||||
import {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
} from "@json-render/ink/catalog";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Render a Spec
|
||||
|
||||
```tsx
|
||||
import { render } from "ink";
|
||||
import { createRenderer, standardComponents } from "@json-render/ink";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
const InkRenderer = createRenderer(catalog, standardComponents);
|
||||
|
||||
const spec = {
|
||||
root: "heading",
|
||||
elements: {
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Hello from the terminal!", level: "h1" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<InkRenderer spec={spec} state={{}} />);
|
||||
```
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Box` | Flexbox layout container (like a terminal `<div>`) |
|
||||
| `Text` | Text output with optional styling (color, bold, italic, etc.) |
|
||||
| `Newline` | Inserts one or more blank lines |
|
||||
| `Spacer` | Flexible empty space that expands to fill available room |
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Heading` | Section heading (h1–h4) |
|
||||
| `Divider` | Horizontal separator line with optional title |
|
||||
| `Badge` | Small colored inline label for status |
|
||||
| `Spinner` | Animated loading spinner with optional label |
|
||||
| `ProgressBar` | Horizontal progress bar (0–1) |
|
||||
| `Sparkline` | Inline sparkline chart using Unicode blocks |
|
||||
| `BarChart` | Horizontal bar chart with labels and values |
|
||||
| `Table` | Tabular data display with headers and rows |
|
||||
| `List` | Bulleted or numbered list |
|
||||
| `ListItem` | Structured list row with title, subtitle, leading/trailing |
|
||||
| `Card` | Bordered container with optional title |
|
||||
| `KeyValue` | Key-value pair display |
|
||||
| `Link` | Clickable URL |
|
||||
| `StatusLine` | Status message with colored icon |
|
||||
| `Markdown` | Renders markdown-formatted text with terminal styling |
|
||||
|
||||
### Interactive
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `TextInput` | Text input field with two-way binding |
|
||||
| `Select` | Selection menu navigated with arrow keys |
|
||||
| `MultiSelect` | Multi-selection with space to toggle, enter to confirm |
|
||||
| `ConfirmInput` | Yes/No confirmation prompt |
|
||||
| `Tabs` | Tab bar navigation with left/right arrow keys |
|
||||
|
||||
## Generate AI Prompts
|
||||
|
||||
```typescript
|
||||
const systemPrompt = catalog.prompt({ system: "You are a terminal assistant." });
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
Use `useUIStream` to progressively render specs from JSONL patch streams:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from "@json-render/ink";
|
||||
|
||||
const { spec, send, isStreaming } = useUIStream({ api: "/api/generate" });
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `createRenderer` | Create an all-in-one renderer component from a catalog |
|
||||
| `Renderer` | Low-level spec renderer |
|
||||
| `JSONUIProvider` | Combined provider (state, visibility, validation, actions, focus) |
|
||||
| `standardComponents` | Pre-built component implementations for all standard components |
|
||||
| `schema` | Ink element tree schema |
|
||||
| `useStateStore` | Access state context (`state`, `get`, `set`, `update`) |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
| `useUIStream` | Stream specs from an API endpoint |
|
||||
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||
|
||||
### Catalog Entry Points
|
||||
|
||||
| Entry Point | Exports |
|
||||
|-------------|---------|
|
||||
| `@json-render/ink` | Components, renderer, hooks, providers |
|
||||
| `@json-render/ink/schema` | `schema`, `InkSchema`, `InkSpec` |
|
||||
| `@json-render/ink/catalog` | `standardComponentDefinitions`, `standardActionDefinitions` |
|
||||
| `@json-render/ink/server` | Server-safe re-exports (schema + catalog, no React dependency) |
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Reference](https://json-render.dev/docs/api/ink)
|
||||
- [Renderers Overview](https://json-render.dev/docs/renderers)
|
||||
- [Ink Chat Example](https://github.com/vercel-labs/json-render/tree/main/examples/ink-chat)
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@json-render/ink",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"cli",
|
||||
"terminal",
|
||||
"ink",
|
||||
"ai",
|
||||
"generative-ui",
|
||||
"llm",
|
||||
"renderer",
|
||||
"tui"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/ink"
|
||||
},
|
||||
"homepage": "https://json-render.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema.d.ts",
|
||||
"import": "./dist/schema.mjs",
|
||||
"require": "./dist/schema.js"
|
||||
},
|
||||
"./catalog": {
|
||||
"types": "./dist/catalog.d.ts",
|
||||
"import": "./dist/catalog.mjs",
|
||||
"require": "./dist/catalog.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.mjs",
|
||||
"require": "./dist/server.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"marked": "^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink": "^6.0.0",
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
Catalog,
|
||||
InferCatalogComponents,
|
||||
InferCatalogActions,
|
||||
InferComponentProps,
|
||||
InferActionParams,
|
||||
StateModel,
|
||||
} from "@json-render/core";
|
||||
|
||||
export type { StateModel };
|
||||
|
||||
// =============================================================================
|
||||
// State Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* State setter function for updating application state
|
||||
*/
|
||||
export type SetState = (path: string, value: unknown) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Component Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Context passed to component render functions
|
||||
* @example
|
||||
* const StatusBadge: ComponentFn<typeof catalog, 'Badge'> = (ctx) => {
|
||||
* return <Text color="green">{ctx.props.label}</Text>
|
||||
* }
|
||||
*/
|
||||
export interface ComponentContext<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> {
|
||||
props: InferComponentProps<C, K>;
|
||||
children?: ReactNode;
|
||||
/** Emit a named event. The renderer resolves the event to an action binding from the element's `on` field. */
|
||||
emit: (event: string) => void;
|
||||
/**
|
||||
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
|
||||
* Maps prop name → absolute state path for write-back.
|
||||
*/
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component render function type for Ink
|
||||
* @example
|
||||
* const Badge: ComponentFn<typeof catalog, 'Badge'> = ({ props }) => (
|
||||
* <Text color="green">{props.label}</Text>
|
||||
* );
|
||||
*/
|
||||
export type ComponentFn<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> = (ctx: ComponentContext<C, K>) => ReactNode;
|
||||
|
||||
/**
|
||||
* Registry of all component render functions for a catalog
|
||||
* @example
|
||||
* const components: Components<typeof myCatalog> = {
|
||||
* Badge: ({ props }) => <Text color="green">{props.label}</Text>,
|
||||
* Card: ({ props, children }) => <Box borderStyle="round">{children}</Box>,
|
||||
* };
|
||||
*/
|
||||
export type Components<C extends Catalog> = {
|
||||
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Action Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Action handler function type
|
||||
* @example
|
||||
* const exit: ActionFn<typeof catalog, 'exit'> = async (params, setState) => {
|
||||
* process.exit(0);
|
||||
* };
|
||||
*/
|
||||
export type ActionFn<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogActions<C>,
|
||||
> = (
|
||||
params: InferActionParams<C, K> | undefined,
|
||||
setState: SetState,
|
||||
state: StateModel,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Registry of all action handlers for a catalog
|
||||
* @example
|
||||
* const actions: Actions<typeof myCatalog> = {
|
||||
* exit: async (params, setState) => { process.exit(0); },
|
||||
* };
|
||||
*/
|
||||
export type Actions<C extends Catalog> = {
|
||||
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
|
||||
};
|
||||
@@ -0,0 +1,629 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Standard Component Definitions for Ink (Terminal)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard component definitions for Ink terminal catalogs.
|
||||
*
|
||||
* These can be used directly or extended with custom components.
|
||||
* All components are built using Ink core primitives only.
|
||||
*/
|
||||
export const standardComponentDefinitions = {
|
||||
// ==========================================================================
|
||||
// Layout Components (Ink Primitives)
|
||||
// ==========================================================================
|
||||
|
||||
Box: {
|
||||
props: z.object({
|
||||
flexDirection: z
|
||||
.enum(["row", "row-reverse", "column", "column-reverse"])
|
||||
.nullable(),
|
||||
alignItems: z
|
||||
.enum(["flex-start", "center", "flex-end", "stretch"])
|
||||
.nullable(),
|
||||
justifyContent: z
|
||||
.enum([
|
||||
"flex-start",
|
||||
"center",
|
||||
"flex-end",
|
||||
"space-between",
|
||||
"space-around",
|
||||
"space-evenly",
|
||||
])
|
||||
.nullable(),
|
||||
flexGrow: z.number().nullable(),
|
||||
flexShrink: z.number().nullable(),
|
||||
flexWrap: z.enum(["nowrap", "wrap", "wrap-reverse"]).nullable(),
|
||||
width: z.union([z.number().max(500), z.string()]).nullable(),
|
||||
height: z.union([z.number().max(500), z.string()]).nullable(),
|
||||
minWidth: z.union([z.number().max(500), z.string()]).nullable(),
|
||||
minHeight: z.union([z.number().max(500), z.string()]).nullable(),
|
||||
padding: z.number().nullable(),
|
||||
paddingX: z.number().nullable(),
|
||||
paddingY: z.number().nullable(),
|
||||
paddingTop: z.number().nullable(),
|
||||
paddingBottom: z.number().nullable(),
|
||||
paddingLeft: z.number().nullable(),
|
||||
paddingRight: z.number().nullable(),
|
||||
margin: z.number().nullable(),
|
||||
marginX: z.number().nullable(),
|
||||
marginY: z.number().nullable(),
|
||||
marginTop: z.number().nullable(),
|
||||
marginBottom: z.number().nullable(),
|
||||
marginLeft: z.number().nullable(),
|
||||
marginRight: z.number().nullable(),
|
||||
gap: z.number().nullable(),
|
||||
columnGap: z.number().nullable(),
|
||||
rowGap: z.number().nullable(),
|
||||
borderStyle: z
|
||||
.enum([
|
||||
"single",
|
||||
"double",
|
||||
"round",
|
||||
"bold",
|
||||
"singleDouble",
|
||||
"doubleSingle",
|
||||
"classic",
|
||||
])
|
||||
.nullable(),
|
||||
borderColor: z.string().nullable(),
|
||||
borderTop: z.boolean().nullable(),
|
||||
borderBottom: z.boolean().nullable(),
|
||||
borderLeft: z.boolean().nullable(),
|
||||
borderRight: z.boolean().nullable(),
|
||||
borderDimColor: z.boolean().nullable(),
|
||||
display: z.enum(["flex", "none"]).nullable(),
|
||||
overflow: z.enum(["visible", "hidden"]).nullable(),
|
||||
backgroundColor: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Flexbox layout container (like a terminal <div>). Use for grouping, spacing, borders, and alignment. Default flexDirection is row.",
|
||||
example: {
|
||||
flexDirection: "column",
|
||||
padding: 1,
|
||||
gap: 1,
|
||||
borderStyle: "round",
|
||||
},
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
color: z.string().nullable(),
|
||||
backgroundColor: z.string().nullable(),
|
||||
bold: z.boolean().nullable(),
|
||||
italic: z.boolean().nullable(),
|
||||
underline: z.boolean().nullable(),
|
||||
strikethrough: z.boolean().nullable(),
|
||||
dimColor: z.boolean().nullable(),
|
||||
inverse: z.boolean().nullable(),
|
||||
wrap: z
|
||||
.enum([
|
||||
"wrap",
|
||||
"truncate",
|
||||
"truncate-end",
|
||||
"truncate-middle",
|
||||
"truncate-start",
|
||||
])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Text output with optional styling (color, bold, italic, etc.). Use for all text content in the terminal.",
|
||||
example: { text: "Hello, world!", bold: true, color: "green" },
|
||||
},
|
||||
|
||||
Newline: {
|
||||
props: z.object({
|
||||
count: z.number().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Inserts one or more blank lines. Must be placed inside a Box with flexDirection column.",
|
||||
example: { count: 1 },
|
||||
},
|
||||
|
||||
Spacer: {
|
||||
props: z.object({}),
|
||||
slots: [],
|
||||
description:
|
||||
"Flexible empty space that expands to fill available room along the main axis. Use between elements to push them apart.",
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Content Components (Higher-Level)
|
||||
// ==========================================================================
|
||||
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Section heading. h1 is bold + underlined, h2 is bold, h3 is bold + dimmed, h4 is dimmed.",
|
||||
example: { text: "Dashboard", level: "h1" },
|
||||
},
|
||||
|
||||
Divider: {
|
||||
props: z.object({
|
||||
character: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
dimColor: z.boolean().nullable(),
|
||||
title: z.string().nullable(),
|
||||
width: z.number().max(500).nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Horizontal separator line. Default width is 40 characters. Optionally includes a centered title.",
|
||||
example: { title: "Section", color: "gray", width: 40 },
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "info", "success", "warning", "error"])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Small colored inline label for status, counts, and categories.",
|
||||
example: { label: "ACTIVE", variant: "success" },
|
||||
},
|
||||
|
||||
Spinner: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Animated loading spinner with optional label text. Uses braille animation characters.",
|
||||
example: { label: "Loading...", color: "cyan" },
|
||||
},
|
||||
|
||||
ProgressBar: {
|
||||
props: z.object({
|
||||
progress: z.number(),
|
||||
width: z.number().max(500).nullable(),
|
||||
color: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Horizontal progress bar. Set progress from 0 to 1. Default width is 30 characters.",
|
||||
example: { progress: 0.65, width: 30, color: "green", label: "Uploading" },
|
||||
},
|
||||
|
||||
Sparkline: {
|
||||
props: z.object({
|
||||
data: z.array(z.number()),
|
||||
width: z.number().max(500).nullable(),
|
||||
color: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Inline sparkline chart using Unicode block characters (▁▂▃▄▅▆▇█). Pass an array of numbers to visualize trends compactly. Set min/max to fix the scale across multiple sparklines.",
|
||||
example: {
|
||||
data: [3, 7, 2, 9, 4, 8, 1, 6, 5],
|
||||
color: "cyan",
|
||||
label: "CPU",
|
||||
},
|
||||
},
|
||||
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
width: z.number().max(500).nullable(),
|
||||
showValues: z.boolean().nullable(),
|
||||
showPercentage: z.boolean().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Horizontal bar chart. Each item has a label, numeric value, and optional color. Set showValues to display raw numbers, showPercentage to show % of total. Default bar width is 30.",
|
||||
example: {
|
||||
data: [
|
||||
{ label: "TypeScript", value: 65, color: "blue" },
|
||||
{ label: "Python", value: 20, color: "yellow" },
|
||||
{ label: "Rust", value: 15, color: "red" },
|
||||
],
|
||||
showPercentage: true,
|
||||
},
|
||||
},
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
columns: z.array(
|
||||
z.object({
|
||||
header: z.string(),
|
||||
key: z.string(),
|
||||
width: z.number().max(200).nullable(),
|
||||
align: z.enum(["left", "center", "right"]).nullable(),
|
||||
}),
|
||||
),
|
||||
rows: z.array(z.record(z.string(), z.string())),
|
||||
borderStyle: z
|
||||
.enum(["single", "double", "round", "bold", "classic"])
|
||||
.nullable(),
|
||||
backgroundColor: z.string().nullable(),
|
||||
headerColor: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Tabular data display with headers and rows. Each row is a record mapping column keys to string values. Set both borderStyle and backgroundColor together so borders share the same shading.",
|
||||
example: {
|
||||
columns: [
|
||||
{ header: "Name", key: "name", width: 20 },
|
||||
{ header: "Status", key: "status", width: 10 },
|
||||
],
|
||||
rows: [
|
||||
{ name: "api-server", status: "running" },
|
||||
{ name: "worker", status: "stopped" },
|
||||
],
|
||||
headerColor: "cyan",
|
||||
},
|
||||
},
|
||||
|
||||
List: {
|
||||
props: z.object({
|
||||
items: z.array(z.string()),
|
||||
ordered: z.boolean().nullable(),
|
||||
bulletChar: z.string().nullable(),
|
||||
spacing: z.number().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Bulleted or numbered list. Each item is a string. Use for simple enumerations.",
|
||||
example: {
|
||||
items: ["Install dependencies", "Run tests", "Deploy"],
|
||||
ordered: true,
|
||||
},
|
||||
},
|
||||
|
||||
ListItem: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
leading: z.string().nullable(),
|
||||
trailing: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Structured list row with title, optional subtitle, and leading/trailing text. Use with repeat for dynamic lists.",
|
||||
example: {
|
||||
title: "package.json",
|
||||
subtitle: "Modified 2 hours ago",
|
||||
leading: "*",
|
||||
trailing: "2.1 KB",
|
||||
},
|
||||
},
|
||||
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
backgroundColor: z.string().nullable(),
|
||||
padding: z.number().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Shaded container with optional title. Renders as a filled background area for grouping related content. Default background is a subtle dark shade.",
|
||||
example: { title: "Details", padding: 1 },
|
||||
},
|
||||
|
||||
KeyValue: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.number(), z.array(z.string())]),
|
||||
labelColor: z.string().nullable(),
|
||||
separator: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Key-value pair display. Renders label and value on the same line. Value can be a string, number, or array of strings (joined with commas). Default separator is a colon.",
|
||||
example: { label: "Status", value: "Running", labelColor: "cyan" },
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
url: z.string(),
|
||||
label: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
'Renders a URL as underlined text. If label is provided, shows "label (url)". Most terminals make URLs clickable automatically.',
|
||||
example: { url: "https://github.com/vercel/next.js", label: "Next.js" },
|
||||
},
|
||||
|
||||
StatusLine: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
status: z.enum(["info", "success", "warning", "error"]).nullable(),
|
||||
icon: z.string().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Status message with colored icon. Default icons: info=ℹ, success=✔, warning=⚠, error=✖.",
|
||||
example: { text: "Build completed successfully", status: "success" },
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
detail: z.string().nullable(),
|
||||
trend: z.enum(["up", "down", "neutral"]).nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Key metric display with prominent value and optional trend indicator. Use for important numbers that deserve visual emphasis (price, temperature, stars, market cap).",
|
||||
example: {
|
||||
label: "Price",
|
||||
value: "$70,686",
|
||||
detail: "24h change",
|
||||
trend: "up",
|
||||
},
|
||||
},
|
||||
|
||||
Callout: {
|
||||
props: z.object({
|
||||
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
|
||||
title: z.string().nullable(),
|
||||
content: z.string(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Highlighted callout block with colored left border. Use for key takeaways, tips, warnings, or important notes that should stand out from surrounding content.",
|
||||
example: {
|
||||
type: "tip",
|
||||
title: "Key Takeaway",
|
||||
content:
|
||||
"Revenue peaked in FY2022 driven by iPhone 13/14 upgrade cycles.",
|
||||
},
|
||||
},
|
||||
|
||||
Timeline: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
date: z.string().nullable(),
|
||||
status: z.enum(["completed", "current", "upcoming"]).nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Vertical timeline showing ordered events, steps, or milestones. Each item has a status-colored dot, title, optional date, and optional description.",
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
title: "Project Started",
|
||||
description: "Initial commit and setup",
|
||||
date: "Jan 2024",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
title: "Beta Release",
|
||||
description: "Public beta launched",
|
||||
date: "Mar 2024",
|
||||
status: "current",
|
||||
},
|
||||
{ title: "v1.0", date: "Q2 2024", status: "upcoming" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Interactive Components
|
||||
// ==========================================================================
|
||||
|
||||
TextInput: {
|
||||
props: z.object({
|
||||
placeholder: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
mask: z.string().nullable(),
|
||||
}),
|
||||
events: ["submit", "change"],
|
||||
slots: [],
|
||||
description:
|
||||
"Text input field for terminal. Use $bindState to bind to the state model for two-way binding. Press Enter to submit. Set mask to '*' for password fields.",
|
||||
example: { placeholder: "Type here...", label: "Name" },
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
value: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
slots: [],
|
||||
description:
|
||||
"Selection menu navigated with arrow keys. Use $bindState on value to bind the selected value to state. Press Enter to confirm selection.",
|
||||
example: {
|
||||
options: [
|
||||
{ label: "Development", value: "dev" },
|
||||
{ label: "Staging", value: "staging" },
|
||||
{ label: "Production", value: "prod" },
|
||||
],
|
||||
label: "Environment",
|
||||
},
|
||||
},
|
||||
|
||||
MultiSelect: {
|
||||
props: z.object({
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
value: z.array(z.string()).nullable(),
|
||||
label: z.string().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
}),
|
||||
events: ["change", "submit"],
|
||||
slots: [],
|
||||
description:
|
||||
"Multi-selection menu. Navigate with arrow keys, toggle with space, confirm with enter. Use $bindState on value to bind the selected values array to state. Set min/max to constrain selection count.",
|
||||
example: {
|
||||
options: [
|
||||
{ label: "TypeScript", value: "ts" },
|
||||
{ label: "Python", value: "py" },
|
||||
{ label: "Rust", value: "rs" },
|
||||
{ label: "Go", value: "go" },
|
||||
],
|
||||
label: "Languages",
|
||||
},
|
||||
},
|
||||
|
||||
ConfirmInput: {
|
||||
props: z.object({
|
||||
message: z.string().nullable(),
|
||||
defaultValue: z.boolean().nullable(),
|
||||
yesLabel: z.string().nullable(),
|
||||
noLabel: z.string().nullable(),
|
||||
}),
|
||||
events: ["confirm", "deny"],
|
||||
slots: [],
|
||||
description:
|
||||
"Yes/No confirmation prompt. Press Y to confirm, N to deny. Use for destructive or irreversible actions.",
|
||||
example: {
|
||||
message: "Delete all files?",
|
||||
},
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
icon: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
value: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Tab bar navigation. Navigate with left/right arrow keys. Use $bindState on value to bind the active tab to state. Place child content inside and use visible conditions on children to show content for the active tab.",
|
||||
example: {
|
||||
tabs: [
|
||||
{ label: "Overview", value: "overview" },
|
||||
{ label: "Logs", value: "logs" },
|
||||
{ label: "Settings", value: "settings" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Markdown: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Renders markdown-formatted text with proper terminal styling. Supports headings (#), **bold**, *italic*, `inline code`, ~~strikethrough~~, fenced code blocks, lists (ordered and unordered), blockquotes (>), and horizontal rules (---).",
|
||||
example: {
|
||||
text: "## Overview\n\nThis is **bold** and *italic* text with `inline code`.\n\n- First item\n- Second item\n\n> A blockquote",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Standard Action Definitions for Ink
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard action definitions for Ink terminal catalogs.
|
||||
*/
|
||||
export const standardActionDefinitions = {
|
||||
setState: {
|
||||
params: z.object({
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
}),
|
||||
description: "Update a value in the state model at the given statePath.",
|
||||
},
|
||||
|
||||
pushState: {
|
||||
params: z.object({
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
clearStatePath: z.string().optional(),
|
||||
}),
|
||||
description:
|
||||
'Append an item to an array in the state model. The value can contain { $state: "/statePath" } references and "$id" for auto IDs. Use clearStatePath to reset another path after pushing.',
|
||||
},
|
||||
|
||||
removeState: {
|
||||
params: z.object({
|
||||
statePath: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
description:
|
||||
"Remove an item from an array in the state model at the given index.",
|
||||
},
|
||||
|
||||
exit: {
|
||||
params: z.object({
|
||||
code: z.number().optional(),
|
||||
}),
|
||||
description:
|
||||
"Exit the terminal application. Optional exit code (default 0).",
|
||||
},
|
||||
|
||||
log: {
|
||||
params: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
description:
|
||||
"Write a message to stdout outside the Ink render. Useful for persistent output that should remain visible after the UI updates.",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type for a component definition
|
||||
*/
|
||||
export type ComponentDefinition = {
|
||||
props: z.ZodType;
|
||||
slots: string[];
|
||||
events?: string[];
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for an action definition
|
||||
*/
|
||||
export type ActionDefinition = {
|
||||
params: z.ZodType;
|
||||
description: string;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { standardComponents } from "./standard";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import {
|
||||
resolveAction,
|
||||
executeAction,
|
||||
type ActionBinding,
|
||||
type ActionHandler,
|
||||
type ActionConfirm,
|
||||
type ResolvedAction,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state";
|
||||
import { useFocusDisable } from "./focus";
|
||||
|
||||
/**
|
||||
* Generate a unique ID for use with the "$id" token.
|
||||
* Uses crypto.randomUUID when available, otherwise a timestamp + random suffix.
|
||||
* No module-level mutable counter — safe across multiple render trees.
|
||||
*/
|
||||
function generateUniqueId(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const MAX_RESOLVE_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* Deep-resolve dynamic value references within an object.
|
||||
*
|
||||
* Supported tokens:
|
||||
* - `{ $state: "/statePath" }` - read a value from state
|
||||
* - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
|
||||
*/
|
||||
function deepResolveValue(
|
||||
value: unknown,
|
||||
get: (path: string) => unknown,
|
||||
depth = 0,
|
||||
): unknown {
|
||||
if (depth > MAX_RESOLVE_DEPTH) return value;
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
if (value === "$id") {
|
||||
return generateUniqueId();
|
||||
}
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
if (keys.length === 1 && typeof obj.$state === "string") {
|
||||
return get(obj.$state as string);
|
||||
}
|
||||
|
||||
if (keys.length === 1 && "$id" in obj) {
|
||||
return generateUniqueId();
|
||||
}
|
||||
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
resolved[key] = deepResolveValue(val, get, depth + 1);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepResolveValue(item, get, depth + 1));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending confirmation state
|
||||
*/
|
||||
export interface PendingConfirmation {
|
||||
/** The resolved action */
|
||||
action: ResolvedAction;
|
||||
/** The action handler */
|
||||
handler: ActionHandler;
|
||||
/** Resolve callback */
|
||||
resolve: () => void;
|
||||
/** Reject callback */
|
||||
reject: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action context value
|
||||
*/
|
||||
export interface ActionContextValue {
|
||||
/** Registered action handlers */
|
||||
handlers: Record<string, ActionHandler>;
|
||||
/** Actions currently executing (count of in-flight executions per action name) */
|
||||
loadingActions: Map<string, number>;
|
||||
/** Pending confirmation dialog */
|
||||
pendingConfirmation: PendingConfirmation | null;
|
||||
/** Execute an action binding */
|
||||
execute: (binding: ActionBinding) => Promise<void>;
|
||||
/** Confirm the pending action */
|
||||
confirm: () => void;
|
||||
/** Cancel the pending action */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const ActionContext = createContext<ActionContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for ActionProvider
|
||||
*/
|
||||
export interface ActionProviderProps {
|
||||
/** Action handlers (custom handlers override built-in actions) */
|
||||
handlers?: Record<string, ActionHandler>;
|
||||
/** Navigation function */
|
||||
navigate?: (path: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for action execution
|
||||
*/
|
||||
export function ActionProvider({
|
||||
handlers: initialHandlers = {},
|
||||
navigate,
|
||||
children,
|
||||
}: ActionProviderProps) {
|
||||
const { get, set, getSnapshot } = useStateStore();
|
||||
// Ref always holds the latest prop-based handlers (avoids stale closures
|
||||
// when createRenderer creates a new Proxy each render).
|
||||
const initialHandlersRef = useRef(initialHandlers);
|
||||
initialHandlersRef.current = initialHandlers;
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
const [loadingActions, setLoadingActions] = useState<Map<string, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [pendingConfirmation, setPendingConfirmation] =
|
||||
useState<PendingConfirmation | null>(null);
|
||||
// Ref tracks current pending confirmation so overlapping confirms can
|
||||
// auto-reject the previous one without a stale closure.
|
||||
const pendingRef = useRef<PendingConfirmation | null>(null);
|
||||
|
||||
const lookupHandler = useCallback(
|
||||
(name: string): ActionHandler | undefined =>
|
||||
initialHandlersRef.current[name],
|
||||
[],
|
||||
);
|
||||
|
||||
const execute = useCallback(
|
||||
async (binding: ActionBinding) => {
|
||||
const resolved = resolveAction(binding, getSnapshot());
|
||||
|
||||
// Check for custom handler override first — allows consumers to override
|
||||
// built-in actions like setState, pushState, etc.
|
||||
const customHandler = lookupHandler(resolved.action);
|
||||
|
||||
// Built-in: setState (overridable)
|
||||
if (resolved.action === "setState" && !customHandler && resolved.params) {
|
||||
const statePath = resolved.params.statePath as string;
|
||||
const value = resolved.params.value;
|
||||
if (statePath) {
|
||||
set(statePath, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: pushState (overridable)
|
||||
if (
|
||||
resolved.action === "pushState" &&
|
||||
!customHandler &&
|
||||
resolved.params
|
||||
) {
|
||||
const statePath = resolved.params.statePath as string;
|
||||
const rawValue = resolved.params.value;
|
||||
if (statePath) {
|
||||
const resolvedValue = deepResolveValue(rawValue, get);
|
||||
const raw = get(statePath);
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
set(statePath, [...arr, resolvedValue]);
|
||||
const clearStatePath = resolved.params.clearStatePath as
|
||||
| string
|
||||
| undefined;
|
||||
if (clearStatePath) {
|
||||
set(clearStatePath, "");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: removeState (overridable)
|
||||
if (
|
||||
resolved.action === "removeState" &&
|
||||
!customHandler &&
|
||||
resolved.params
|
||||
) {
|
||||
const statePath = resolved.params.statePath as string;
|
||||
const index = resolved.params.index as number;
|
||||
if (statePath !== undefined && index !== undefined) {
|
||||
const raw = get(statePath);
|
||||
if (!Array.isArray(raw)) return;
|
||||
set(
|
||||
statePath,
|
||||
raw.filter((_, i) => i !== index),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: log (overridable)
|
||||
if (resolved.action === "log" && !customHandler) {
|
||||
const message =
|
||||
resolved.params?.message ?? resolved.params?.value ?? "";
|
||||
console.log("[json-render]", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: exit (always delegates to handler if available)
|
||||
if (resolved.action === "exit" && !customHandler) {
|
||||
// No-op when no handler is provided
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = customHandler;
|
||||
|
||||
if (!handler) {
|
||||
console.warn(
|
||||
`[json-render] No handler registered for action: ${resolved.action}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If confirmation is required, show dialog and wait for user response.
|
||||
// Uses resolve(boolean) instead of reject() to avoid unhandled rejections.
|
||||
if (resolved.confirm) {
|
||||
// Auto-reject any existing pending confirmation so its promise resolves
|
||||
// (prevents orphaned promises when a second confirm fires).
|
||||
pendingRef.current?.reject();
|
||||
|
||||
const confirmed = await new Promise<boolean>((res) => {
|
||||
const entry: PendingConfirmation = {
|
||||
action: resolved,
|
||||
handler,
|
||||
resolve: () => {
|
||||
pendingRef.current = null;
|
||||
setPendingConfirmation(null);
|
||||
res(true);
|
||||
},
|
||||
reject: () => {
|
||||
pendingRef.current = null;
|
||||
setPendingConfirmation(null);
|
||||
res(false);
|
||||
},
|
||||
};
|
||||
pendingRef.current = entry;
|
||||
setPendingConfirmation(entry);
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const actionName = resolved.action;
|
||||
setLoadingActions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(actionName, (next.get(actionName) ?? 0) + 1);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await executeAction({
|
||||
action: resolved,
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: navigateRef.current,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoadingActions((prev) => {
|
||||
const next = new Map(prev);
|
||||
const count = (next.get(actionName) ?? 1) - 1;
|
||||
if (count <= 0) {
|
||||
next.delete(actionName);
|
||||
} else {
|
||||
next.set(actionName, count);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[lookupHandler, get, set, getSnapshot],
|
||||
);
|
||||
|
||||
// Use pendingRef for confirm/cancel to avoid stale closure issues
|
||||
const confirm = useCallback(() => {
|
||||
pendingRef.current?.resolve();
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
pendingRef.current?.reject();
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ActionContextValue>(
|
||||
() => ({
|
||||
handlers: initialHandlers,
|
||||
loadingActions,
|
||||
pendingConfirmation,
|
||||
execute,
|
||||
confirm,
|
||||
cancel,
|
||||
}),
|
||||
[
|
||||
initialHandlers,
|
||||
loadingActions,
|
||||
pendingConfirmation,
|
||||
execute,
|
||||
confirm,
|
||||
cancel,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContext.Provider value={value}>{children}</ActionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access action context
|
||||
*/
|
||||
export function useActions(): ActionContextValue {
|
||||
const ctx = useContext(ActionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useActions must be used within an ActionProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for a single action binding — returns execute and loading state.
|
||||
*/
|
||||
export function useAction(binding: ActionBinding): {
|
||||
execute: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { execute, loadingActions } = useActions();
|
||||
const executeAction = useCallback(() => execute(binding), [execute, binding]);
|
||||
const isLoading = (loadingActions.get(binding.action) ?? 0) > 0;
|
||||
return { execute: executeAction, isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ConfirmDialog component
|
||||
*/
|
||||
export interface ConfirmDialogProps {
|
||||
/** The confirmation config */
|
||||
confirm: ActionConfirm;
|
||||
/** Called when confirmed */
|
||||
onConfirm: () => void;
|
||||
/** Called when cancelled */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal confirmation dialog using Ink's Box/Text and useInput.
|
||||
* Press Y to confirm, N or Escape to cancel.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
confirm,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const isDanger = confirm.variant === "danger";
|
||||
|
||||
// Suppress Tab cycling while modal is open
|
||||
useFocusDisable(true);
|
||||
|
||||
// ConfirmDialog always captures input when mounted (it's modal)
|
||||
useInput((input, key) => {
|
||||
if (input.toLowerCase() === "y") {
|
||||
onConfirm();
|
||||
} else if (input.toLowerCase() === "n" || key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={isDanger ? "red" : "blue"}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>{confirm.title}</Text>
|
||||
<Text dimColor>{confirm.message}</Text>
|
||||
<Box marginTop={1} gap={2}>
|
||||
<Text>
|
||||
<Text color={isDanger ? "red" : "green"} bold>
|
||||
[Y]
|
||||
</Text>{" "}
|
||||
{confirm.confirmLabel ?? "Confirm"}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>[N]</Text> {confirm.cancelLabel ?? "Cancel"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useInput } from "ink";
|
||||
|
||||
// =============================================================================
|
||||
// Focus context — tracks which interactive component receives keyboard input.
|
||||
// Tab/Shift+Tab cycles focus. useInput's `isActive` gates input delivery.
|
||||
// =============================================================================
|
||||
|
||||
interface FocusContextValue {
|
||||
/** Register an interactive component by ID. */
|
||||
register: (id: string) => void;
|
||||
/** Unregister when unmounting. */
|
||||
unregister: (id: string) => void;
|
||||
/** The currently focused component's ID (or null). */
|
||||
focusedId: string | null;
|
||||
/** Check if a given ID is the focused component. */
|
||||
isFocused: (id: string) => boolean;
|
||||
/** Suppress Tab cycling (e.g. during modal dialogs). */
|
||||
setDisabled: (disabled: boolean) => void;
|
||||
}
|
||||
|
||||
const FocusContext = createContext<FocusContextValue | null>(null);
|
||||
|
||||
// Reducer state combines ids and focusedId for atomic updates.
|
||||
interface FocusState {
|
||||
ids: string[];
|
||||
focusedId: string | null;
|
||||
}
|
||||
|
||||
type FocusAction =
|
||||
| { type: "register"; id: string }
|
||||
| { type: "unregister"; id: string }
|
||||
| { type: "cycle"; direction: "next" | "prev" };
|
||||
|
||||
function focusReducer(state: FocusState, action: FocusAction): FocusState {
|
||||
switch (action.type) {
|
||||
case "register": {
|
||||
if (state.ids.includes(action.id)) return state;
|
||||
const ids = [...state.ids, action.id];
|
||||
// Auto-focus if first interactive component
|
||||
const focusedId = state.focusedId ?? action.id;
|
||||
return { ids, focusedId };
|
||||
}
|
||||
case "unregister": {
|
||||
const ids = state.ids.filter((x) => x !== action.id);
|
||||
let focusedId = state.focusedId;
|
||||
if (focusedId === action.id) {
|
||||
// Move focus to first remaining element, or null
|
||||
focusedId = ids[0] ?? null;
|
||||
}
|
||||
return { ids, focusedId };
|
||||
}
|
||||
case "cycle": {
|
||||
const { ids, focusedId } = state;
|
||||
if (ids.length === 0) return { ...state, focusedId: null };
|
||||
const idx = focusedId ? ids.indexOf(focusedId) : -1;
|
||||
const next =
|
||||
action.direction === "next"
|
||||
? ids[(idx + 1) % ids.length]!
|
||||
: ids[(idx - 1 + ids.length) % ids.length]!;
|
||||
return { ...state, focusedId: next };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function FocusProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, dispatch] = useReducer(focusReducer, {
|
||||
ids: [],
|
||||
focusedId: null,
|
||||
});
|
||||
const [disableCount, setDisableCount] = useState(0);
|
||||
const disabled = disableCount > 0;
|
||||
|
||||
// Counter-based disable: multiple concurrent callers (e.g. nested modals)
|
||||
// each increment on mount and decrement on unmount. Focus is only re-enabled
|
||||
// when all callers have released.
|
||||
const setDisabled = useCallback((value: boolean) => {
|
||||
setDisableCount((prev) => (value ? prev + 1 : Math.max(0, prev - 1)));
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
(id: string) => dispatch({ type: "register", id }),
|
||||
[],
|
||||
);
|
||||
|
||||
const unregister = useCallback(
|
||||
(id: string) => dispatch({ type: "unregister", id }),
|
||||
[],
|
||||
);
|
||||
|
||||
// When disabled (e.g. modal dialog open), all components report isActive=false
|
||||
// so their useInput({ isActive }) stops processing keystrokes.
|
||||
const isFocused = useCallback(
|
||||
(id: string) => !disabled && state.focusedId === id,
|
||||
[state.focusedId, disabled],
|
||||
);
|
||||
|
||||
// Tab / Shift+Tab to cycle focus (suppressed when disabled, e.g. during modals)
|
||||
useInput((_input, key) => {
|
||||
if (disabled) return;
|
||||
if (!key.tab) return;
|
||||
dispatch({ type: "cycle", direction: key.shift ? "prev" : "next" });
|
||||
});
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
register,
|
||||
unregister,
|
||||
focusedId: state.focusedId,
|
||||
isFocused,
|
||||
setDisabled,
|
||||
}),
|
||||
[register, unregister, state.focusedId, isFocused, setDisabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={value}>{children}</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for interactive components. Registers on mount via useEffect,
|
||||
* unregisters on unmount. Returns `isActive` boolean for gating `useInput`.
|
||||
*
|
||||
* Uses React.useId() for stable, instance-scoped IDs (no module-level counters).
|
||||
*/
|
||||
export function useFocus(): { isActive: boolean; id: string } {
|
||||
const ctx = useContext(FocusContext);
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
ctx?.register(id);
|
||||
return () => {
|
||||
ctx?.unregister(id);
|
||||
};
|
||||
}, [ctx, id]);
|
||||
|
||||
const isActive = ctx?.isFocused(id) ?? false;
|
||||
return { isActive, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to suppress/restore Tab cycling.
|
||||
* Used by modal dialogs (e.g. ConfirmDialog) to prevent background focus changes.
|
||||
*
|
||||
* Tracks the disabled value at effect time via a ref to avoid double-decrement
|
||||
* when the `disabled` prop toggles from true to false.
|
||||
*/
|
||||
export function useFocusDisable(disabled: boolean): void {
|
||||
const ctx = useContext(FocusContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
ctx?.setDisabled(true);
|
||||
return () => {
|
||||
ctx?.setDisabled(false);
|
||||
};
|
||||
}, [ctx, disabled]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Repeat scope value provided to child elements inside a repeated element.
|
||||
*/
|
||||
export interface RepeatScopeValue {
|
||||
/** The current array item object */
|
||||
item: unknown;
|
||||
/** Index of the current item in the array */
|
||||
index: number;
|
||||
/** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides repeat scope to child elements so $item and $index expressions resolve correctly.
|
||||
*/
|
||||
export function RepeatScopeProvider({
|
||||
item,
|
||||
index,
|
||||
basePath,
|
||||
children,
|
||||
}: RepeatScopeValue & { children: ReactNode }) {
|
||||
const value = useMemo(
|
||||
() => ({ item, index, basePath }),
|
||||
[item, index, basePath],
|
||||
);
|
||||
return (
|
||||
<RepeatScopeContext.Provider value={value}>
|
||||
{children}
|
||||
</RepeatScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current repeat scope (or null if not inside a repeated element).
|
||||
*/
|
||||
export function useRepeatScope(): RepeatScopeValue | null {
|
||||
return useContext(RepeatScopeContext);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
} from "./state";
|
||||
|
||||
describe("state re-exports (smoke test)", () => {
|
||||
it("StateProvider + useStateStore round-trip", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.get("/count")).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.set("/count", 42);
|
||||
});
|
||||
|
||||
expect(result.current.state.count).toBe(42);
|
||||
});
|
||||
|
||||
it("useStateValue reads from state", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/name"), { wrapper });
|
||||
|
||||
expect(result.current).toBe("Alice");
|
||||
});
|
||||
|
||||
it("useStateBinding returns value and setter", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
|
||||
|
||||
const [value, setValue] = result.current;
|
||||
expect(value).toBe(1);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "@internal/react-state";
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
runValidation,
|
||||
type ValidationConfig,
|
||||
type ValidationFunction,
|
||||
type ValidationResult,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state";
|
||||
|
||||
/**
|
||||
* Field validation state
|
||||
*/
|
||||
export interface FieldValidationState {
|
||||
/** Whether the field has been touched */
|
||||
touched: boolean;
|
||||
/** Whether the field has been validated */
|
||||
validated: boolean;
|
||||
/** Validation result */
|
||||
result: ValidationResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation context value
|
||||
*/
|
||||
export interface ValidationContextValue {
|
||||
/** Custom validation functions from catalog */
|
||||
customFunctions: Record<string, ValidationFunction>;
|
||||
/** Validation state by field path */
|
||||
fieldStates: Record<string, FieldValidationState>;
|
||||
/** Validate a field */
|
||||
validate: (path: string, config: ValidationConfig) => ValidationResult;
|
||||
/** Mark field as touched */
|
||||
touch: (path: string) => void;
|
||||
/** Clear validation for a field */
|
||||
clear: (path: string) => void;
|
||||
/** Validate all fields */
|
||||
validateAll: () => boolean;
|
||||
/** Register field config */
|
||||
registerField: (path: string, config: ValidationConfig) => void;
|
||||
/** Unregister field (removes from validateAll and clears state) */
|
||||
unregisterField: (path: string) => void;
|
||||
}
|
||||
|
||||
const ValidationContext = createContext<ValidationContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for ValidationProvider
|
||||
*/
|
||||
export interface ValidationProviderProps {
|
||||
/** Custom validation functions from catalog */
|
||||
customFunctions?: Record<string, ValidationFunction>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two DynamicValue args records shallowly.
|
||||
*/
|
||||
function dynamicArgsEqual(
|
||||
a: Record<string, unknown> | undefined,
|
||||
b: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
const va = a[key];
|
||||
const vb = b[key];
|
||||
if (va === vb) continue;
|
||||
if (
|
||||
typeof va === "object" &&
|
||||
va !== null &&
|
||||
typeof vb === "object" &&
|
||||
vb !== null
|
||||
) {
|
||||
const sa = (va as Record<string, unknown>).$state;
|
||||
const sb = (vb as Record<string, unknown>).$state;
|
||||
if (typeof sa === "string" && sa === sb) continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality check for ValidationConfig.
|
||||
*/
|
||||
function validationConfigEqual(
|
||||
a: ValidationConfig,
|
||||
b: ValidationConfig,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.validateOn !== b.validateOn) return false;
|
||||
|
||||
const ac = a.checks ?? [];
|
||||
const bc = b.checks ?? [];
|
||||
if (ac.length !== bc.length) return false;
|
||||
|
||||
for (let i = 0; i < ac.length; i++) {
|
||||
const ca = ac[i]!;
|
||||
const cb = bc[i]!;
|
||||
if (ca.type !== cb.type) return false;
|
||||
if (ca.message !== cb.message) return false;
|
||||
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for validation
|
||||
*/
|
||||
export function ValidationProvider({
|
||||
customFunctions = {},
|
||||
children,
|
||||
}: ValidationProviderProps) {
|
||||
const { getSnapshot } = useStateStore();
|
||||
const [fieldStates, setFieldStates] = useState<
|
||||
Record<string, FieldValidationState>
|
||||
>({});
|
||||
const [fieldConfigs, setFieldConfigs] = useState<
|
||||
Record<string, ValidationConfig>
|
||||
>({});
|
||||
|
||||
const registerField = useCallback(
|
||||
(path: string, config: ValidationConfig) => {
|
||||
setFieldConfigs((prev) => {
|
||||
const existing = prev[path];
|
||||
if (existing && validationConfigEqual(existing, config)) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [path]: config };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterField = useCallback((path: string) => {
|
||||
setFieldConfigs((prev) => {
|
||||
const { [path]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setFieldStates((prev) => {
|
||||
const { [path]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validate = useCallback(
|
||||
(path: string, config: ValidationConfig): ValidationResult => {
|
||||
const currentState = getSnapshot();
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
let value: unknown = currentState;
|
||||
for (const seg of segments) {
|
||||
if (value != null && typeof value === "object") {
|
||||
value = (value as Record<string, unknown>)[seg];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const result = runValidation(config, {
|
||||
value,
|
||||
stateModel: currentState,
|
||||
customFunctions,
|
||||
});
|
||||
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
[path]: {
|
||||
touched: prev[path]?.touched ?? true,
|
||||
validated: true,
|
||||
result,
|
||||
},
|
||||
}));
|
||||
|
||||
return result;
|
||||
},
|
||||
[getSnapshot, customFunctions],
|
||||
);
|
||||
|
||||
const touch = useCallback((path: string) => {
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
[path]: {
|
||||
...prev[path],
|
||||
touched: true,
|
||||
validated: prev[path]?.validated ?? false,
|
||||
result: prev[path]?.result ?? null,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
setFieldStates((prev) => {
|
||||
const { [path]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => {
|
||||
let allValid = true;
|
||||
for (const [path, config] of Object.entries(fieldConfigs)) {
|
||||
const result = validate(path, config);
|
||||
if (!result.valid) {
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
return allValid;
|
||||
}, [fieldConfigs, validate]);
|
||||
|
||||
const value = useMemo<ValidationContextValue>(
|
||||
() => ({
|
||||
customFunctions,
|
||||
fieldStates,
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
validateAll,
|
||||
registerField,
|
||||
unregisterField,
|
||||
}),
|
||||
[
|
||||
customFunctions,
|
||||
fieldStates,
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
validateAll,
|
||||
registerField,
|
||||
unregisterField,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ValidationContext.Provider value={value}>
|
||||
{children}
|
||||
</ValidationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access validation context
|
||||
*/
|
||||
export function useValidation(): ValidationContextValue {
|
||||
const ctx = useContext(ValidationContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useValidation must be used within a ValidationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to optionally access validation context (returns null if outside provider).
|
||||
*/
|
||||
export function useOptionalValidation(): ValidationContextValue | null {
|
||||
return useContext(ValidationContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get validation state for a field
|
||||
*/
|
||||
export function useFieldValidation(
|
||||
path: string,
|
||||
config?: ValidationConfig,
|
||||
): {
|
||||
state: FieldValidationState;
|
||||
validate: () => ValidationResult;
|
||||
touch: () => void;
|
||||
clear: () => void;
|
||||
errors: string[];
|
||||
isValid: boolean;
|
||||
} {
|
||||
const {
|
||||
fieldStates,
|
||||
validate: validateField,
|
||||
touch: touchField,
|
||||
clear: clearField,
|
||||
registerField,
|
||||
unregisterField,
|
||||
} = useValidation();
|
||||
|
||||
// Register whenever config changes (registerField deduplicates via structural equality)
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
registerField(path, config);
|
||||
}
|
||||
}, [path, config, registerField]);
|
||||
|
||||
// Unregister only on true unmount — avoids clearing validation state
|
||||
// when config identity changes (e.g. inline objects re-created each render)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unregisterField(path);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path]);
|
||||
|
||||
const state = fieldStates[path] ?? {
|
||||
touched: false,
|
||||
validated: false,
|
||||
result: null,
|
||||
};
|
||||
|
||||
const validate = useCallback(
|
||||
() => validateField(path, config ?? { checks: [] }),
|
||||
[path, config, validateField],
|
||||
);
|
||||
|
||||
const touch = useCallback(() => touchField(path), [path, touchField]);
|
||||
const clear = useCallback(() => clearField(path), [path, clearField]);
|
||||
|
||||
return {
|
||||
state,
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
errors: state.result?.errors ?? [],
|
||||
isValid: state.result?.valid ?? true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
|
||||
import { StateProvider } from "./state";
|
||||
|
||||
const createWrapper =
|
||||
(data: Record<string, unknown> = {}) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={data}>
|
||||
<VisibilityProvider>{children}</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
describe("useVisibility", () => {
|
||||
it("provides isVisible function", () => {
|
||||
const { result } = renderHook(() => useVisibility(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(typeof result.current.isVisible).toBe("function");
|
||||
});
|
||||
|
||||
it("provides visibility context", () => {
|
||||
const { result } = renderHook(() => useVisibility(), {
|
||||
wrapper: createWrapper({ test: true }),
|
||||
});
|
||||
|
||||
expect(result.current.ctx.stateModel).toEqual({ test: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useIsVisible", () => {
|
||||
it("returns true for undefined condition", () => {
|
||||
const { result } = renderHook(() => useIsVisible(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for true condition", () => {
|
||||
const { result } = renderHook(() => useIsVisible(true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for false condition", () => {
|
||||
const { result } = renderHook(() => useIsVisible(false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("evaluates $state conditions against data", () => {
|
||||
const { result: trueResult } = renderHook(
|
||||
() => useIsVisible({ $state: "/isVisible" }),
|
||||
{ wrapper: createWrapper({ isVisible: true }) },
|
||||
);
|
||||
expect(trueResult.current).toBe(true);
|
||||
|
||||
const { result: falseResult } = renderHook(
|
||||
() => useIsVisible({ $state: "/isVisible" }),
|
||||
{ wrapper: createWrapper({ isVisible: false }) },
|
||||
);
|
||||
expect(falseResult.current).toBe(false);
|
||||
});
|
||||
|
||||
it("evaluates equality conditions", () => {
|
||||
const { result } = renderHook(
|
||||
() => useIsVisible({ $state: "/count", eq: 1 }),
|
||||
{ wrapper: createWrapper({ count: 1 }) },
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluates array conditions (implicit AND)", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useIsVisible([
|
||||
{ $state: "/user/isAdmin" },
|
||||
{ $state: "/count", eq: 5 },
|
||||
]),
|
||||
{ wrapper: createWrapper({ user: { isAdmin: true }, count: 5 }) },
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
evaluateVisibility,
|
||||
type VisibilityCondition,
|
||||
type VisibilityContext as CoreVisibilityContext,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state";
|
||||
|
||||
/**
|
||||
* Visibility context value
|
||||
*/
|
||||
export interface VisibilityContextValue {
|
||||
/** Evaluate a visibility condition */
|
||||
isVisible: (condition: VisibilityCondition | undefined) => boolean;
|
||||
/** The underlying visibility context */
|
||||
ctx: CoreVisibilityContext;
|
||||
}
|
||||
|
||||
const VisibilityContext = createContext<VisibilityContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for VisibilityProvider
|
||||
*/
|
||||
export interface VisibilityProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for visibility evaluation
|
||||
*/
|
||||
export function VisibilityProvider({ children }: VisibilityProviderProps) {
|
||||
const { state } = useStateStore();
|
||||
|
||||
const ctx: CoreVisibilityContext = useMemo(
|
||||
() => ({
|
||||
stateModel: state,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => (condition: VisibilityCondition | undefined) =>
|
||||
evaluateVisibility(condition, ctx),
|
||||
[ctx],
|
||||
);
|
||||
|
||||
const value = useMemo<VisibilityContextValue>(
|
||||
() => ({ isVisible, ctx }),
|
||||
[isVisible, ctx],
|
||||
);
|
||||
|
||||
return (
|
||||
<VisibilityContext.Provider value={value}>
|
||||
{children}
|
||||
</VisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access visibility evaluation
|
||||
*/
|
||||
export function useVisibility(): VisibilityContextValue {
|
||||
const ctx = useContext(VisibilityContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useVisibility must be used within a VisibilityProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a condition is visible
|
||||
*/
|
||||
export function useIsVisible(
|
||||
condition: VisibilityCondition | undefined,
|
||||
): boolean {
|
||||
const { isVisible } = useVisibility();
|
||||
return isVisible(condition);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { flatToTree } from "./hooks";
|
||||
|
||||
describe("flatToTree", () => {
|
||||
it("converts array of elements to tree structure", () => {
|
||||
const elements = [
|
||||
{ key: "container", type: "Box", props: {}, parentKey: null },
|
||||
{
|
||||
key: "text1",
|
||||
type: "Text",
|
||||
props: { text: "Hello" },
|
||||
parentKey: "container",
|
||||
},
|
||||
{
|
||||
key: "text2",
|
||||
type: "Text",
|
||||
props: { text: "World" },
|
||||
parentKey: "container",
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("container");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(3);
|
||||
expect(tree.elements["container"]).toBeDefined();
|
||||
expect(tree.elements["text1"]).toBeDefined();
|
||||
expect(tree.elements["text2"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("builds parent-child relationships", () => {
|
||||
const elements = [
|
||||
{ key: "root", type: "Box", props: {}, parentKey: null },
|
||||
{ key: "child1", type: "Text", props: {}, parentKey: "root" },
|
||||
{ key: "child2", type: "Text", props: {}, parentKey: "root" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["root"]!.children).toHaveLength(2);
|
||||
expect(tree.elements["root"]!.children).toContain("child1");
|
||||
expect(tree.elements["root"]!.children).toContain("child2");
|
||||
});
|
||||
|
||||
it("handles single root element", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "only",
|
||||
type: "Text",
|
||||
props: { text: "Single" },
|
||||
parentKey: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("only");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles deeply nested elements", () => {
|
||||
const elements = [
|
||||
{ key: "level0", type: "Box", props: {}, parentKey: null },
|
||||
{ key: "level1", type: "Box", props: {}, parentKey: "level0" },
|
||||
{ key: "level2", type: "Box", props: {}, parentKey: "level1" },
|
||||
{ key: "level3", type: "Text", props: {}, parentKey: "level2" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("level0");
|
||||
expect(tree.elements["level0"]!.children).toContain("level1");
|
||||
expect(tree.elements["level1"]!.children).toContain("level2");
|
||||
expect(tree.elements["level2"]!.children).toContain("level3");
|
||||
});
|
||||
|
||||
it("preserves element props", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "heading",
|
||||
type: "Heading",
|
||||
props: { text: "Dashboard", level: "h1" },
|
||||
parentKey: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["heading"]!.props).toEqual({
|
||||
text: "Dashboard",
|
||||
level: "h1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves visibility conditions", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "conditional",
|
||||
type: "Text",
|
||||
props: {},
|
||||
parentKey: null,
|
||||
visible: { $state: "/isVisible" },
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["conditional"]!.visible).toEqual({
|
||||
$state: "/isVisible",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty elements array", () => {
|
||||
const tree = flatToTree([]);
|
||||
|
||||
expect(tree.root).toBe("");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles multiple children correctly", () => {
|
||||
const elements = [
|
||||
{ key: "parent", type: "Box", props: {}, parentKey: null },
|
||||
{ key: "a", type: "Card", props: {}, parentKey: "parent" },
|
||||
{ key: "b", type: "Card", props: {}, parentKey: "parent" },
|
||||
{ key: "c", type: "Card", props: {}, parentKey: "parent" },
|
||||
{ key: "d", type: "Card", props: {}, parentKey: "parent" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["parent"]!.children).toHaveLength(4);
|
||||
expect(tree.elements["parent"]!.children).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type {
|
||||
Spec,
|
||||
UIElement,
|
||||
FlatElement,
|
||||
SpecStreamLine,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
applySpecPatch,
|
||||
validateSpec,
|
||||
autoFixSpec,
|
||||
formatSpecIssues,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./contexts/state";
|
||||
|
||||
// =============================================================================
|
||||
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook for two-way bound props. Returns `[value, setValue]` where:
|
||||
*
|
||||
* - `value` is the already-resolved prop value (passed through from render props)
|
||||
* - `setValue` writes back to the bound state path (no-op if not bound)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [value, setValue] = useBoundProp<string>(element.props.value, bindings?.value);
|
||||
* ```
|
||||
*/
|
||||
export function useBoundProp<T>(
|
||||
propValue: T | undefined,
|
||||
bindingPath: string | undefined,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { set } = useStateStore();
|
||||
const setValue = useCallback(
|
||||
(value: T) => {
|
||||
if (bindingPath) set(bindingPath, value);
|
||||
},
|
||||
[bindingPath, set],
|
||||
);
|
||||
return [propValue, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of attempting to parse a JSONL line.
|
||||
* - `patch`: successfully parsed patch (or null)
|
||||
* - `malformed`: true only if the line looked like JSON (starts with `{`)
|
||||
* but could not be parsed. Plain text commentary is NOT malformed.
|
||||
*/
|
||||
interface ParseResult {
|
||||
patch: SpecStreamLine | null;
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line looks like it's attempting to be JSON.
|
||||
* LLMs often output commentary text before/between patches — those
|
||||
* lines should be skipped, not treated as malformed.
|
||||
*/
|
||||
function looksLikeJson(line: string): boolean {
|
||||
return line.startsWith("{") || line.startsWith("[");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single JSON patch line with LLM-specific recovery.
|
||||
* Wraps core's parseSpecStreamLine with recovery for common LLM errors
|
||||
* like trailing extra braces.
|
||||
*
|
||||
* Returns a ParseResult so the caller can distinguish between:
|
||||
* - Successfully parsed patch
|
||||
* - Commentary text (skip silently)
|
||||
* - Genuinely malformed JSON (trigger retry)
|
||||
*/
|
||||
function parsePatchLine(line: string): ParseResult {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) {
|
||||
return { patch: null, malformed: false };
|
||||
}
|
||||
|
||||
// If it doesn't look like JSON at all, it's commentary — skip it
|
||||
if (!looksLikeJson(trimmed)) {
|
||||
return { patch: null, malformed: false };
|
||||
}
|
||||
|
||||
// Try parsing as-is first
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
// Validate it's a patch operation (must have `op` field)
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.op === "string") {
|
||||
return { patch: parsed as SpecStreamLine, malformed: false };
|
||||
}
|
||||
// Valid JSON but not a patch — skip (could be metadata)
|
||||
return { patch: null, malformed: false };
|
||||
} catch {
|
||||
// Fall through to recovery
|
||||
}
|
||||
|
||||
// Recovery: strip trailing extra braces/brackets one at a time
|
||||
// LLMs commonly generate extra closing characters in nested JSON
|
||||
let attempt = trimmed;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const last = attempt[attempt.length - 1];
|
||||
if (last === "}" || last === "]") {
|
||||
attempt = attempt.slice(0, -1);
|
||||
try {
|
||||
const result = JSON.parse(attempt);
|
||||
// Validate that the parsed result is actually a patch operation
|
||||
if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
typeof result.op === "string"
|
||||
) {
|
||||
console.warn(
|
||||
`[json-render] Recovered malformed JSONL line by removing ${i + 1} trailing '${last}'`,
|
||||
);
|
||||
return { patch: result as SpecStreamLine, malformed: false };
|
||||
}
|
||||
// Valid JSON but not a patch — treat as malformed
|
||||
return { patch: null, malformed: true };
|
||||
} catch {
|
||||
// Keep stripping
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Looks like JSON but couldn't parse — genuinely malformed
|
||||
return { patch: null, malformed: true };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stream result types
|
||||
// =============================================================================
|
||||
|
||||
/** Result of a single stream request */
|
||||
interface StreamResult {
|
||||
/** The spec after applying all successfully parsed patches */
|
||||
spec: Spec;
|
||||
/** Whether the stream completed naturally (vs. being aborted) */
|
||||
completed: boolean;
|
||||
/** Malformed lines that could not be parsed (even after recovery) */
|
||||
malformedLines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for useUIStream
|
||||
*/
|
||||
export interface UseUIStreamOptions {
|
||||
/** API endpoint */
|
||||
api: string;
|
||||
/** Callback when complete */
|
||||
onComplete?: (spec: Spec) => void;
|
||||
/** Callback on error */
|
||||
onError?: (error: Error) => void;
|
||||
/**
|
||||
* Custom fetch implementation with ReadableStream support.
|
||||
*
|
||||
* Falls back to the global `fetch` if not provided.
|
||||
*/
|
||||
fetch?: (url: string, init?: RequestInit) => Promise<Response>;
|
||||
/**
|
||||
* Enable validation and auto-repair.
|
||||
*
|
||||
* When true:
|
||||
* - **Mid-stream**: Each JSONL line is validated as it arrives. If a line
|
||||
* is malformed JSON (and recovery fails), the stream is aborted
|
||||
* immediately and a repair prompt is sent to continue generation.
|
||||
* - **Post-stream**: After the stream completes, structural validation
|
||||
* runs (missing children, visible-in-props, etc.). Issues that can be
|
||||
* auto-fixed are fixed locally; remaining errors trigger a repair prompt.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
validate?: boolean;
|
||||
/**
|
||||
* Maximum number of automatic repair retries (covers both mid-stream
|
||||
* and post-stream retries combined). Defaults to 5.
|
||||
*/
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useUIStream
|
||||
*/
|
||||
export interface UseUIStreamReturn {
|
||||
/** Current UI spec */
|
||||
spec: Spec | null;
|
||||
/** Whether currently streaming */
|
||||
isStreaming: boolean;
|
||||
/** Error if any */
|
||||
error: Error | null;
|
||||
/** Send a prompt to generate UI */
|
||||
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
||||
/** Stop the current generation */
|
||||
stop: () => void;
|
||||
/** Clear the current spec */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for streaming UI generation via JSONL patches.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { spec, isStreaming, send } = useUIStream({
|
||||
* api: "/api/generate-ui",
|
||||
* onComplete: (spec) => console.log("Done!", spec),
|
||||
* });
|
||||
*
|
||||
* // Trigger generation
|
||||
* await send("Create a dashboard with stats");
|
||||
*
|
||||
* // Render the spec
|
||||
* <Renderer spec={spec} loading={isStreaming} />
|
||||
* ```
|
||||
*/
|
||||
export function useUIStream({
|
||||
api,
|
||||
onComplete,
|
||||
onError,
|
||||
fetch: fetchFn = globalThis.fetch,
|
||||
validate: enableValidation = false,
|
||||
maxRetries = 5,
|
||||
}: UseUIStreamOptions): UseUIStreamReturn {
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Tracks the current request so only the latest one updates isStreaming.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
// Use refs for callbacks to avoid stale closures and unnecessary
|
||||
// re-creation of `send` when consumers pass inline arrow functions.
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSpec(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stream a single request. Returns the result including whether the
|
||||
* stream completed and any malformed lines encountered.
|
||||
*
|
||||
* When `abortOnMalformed` is true, the stream is aborted on the first
|
||||
* malformed line so the caller can retry immediately.
|
||||
*/
|
||||
const streamRequest = useCallback(
|
||||
async (
|
||||
prompt: string,
|
||||
context: Record<string, unknown> | undefined,
|
||||
initialSpec: Spec,
|
||||
abortOnMalformed: boolean,
|
||||
controller: AbortController,
|
||||
): Promise<StreamResult> => {
|
||||
let currentSpec = initialSpec;
|
||||
setSpec(currentSpec);
|
||||
const malformedLines: string[] = [];
|
||||
|
||||
const response = await fetchFn(api, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
context,
|
||||
currentSpec,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP error: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let aborted = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const decoded = decoder.decode(value, { stream: true });
|
||||
buffer += decoded;
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const { patch, malformed } = parsePatchLine(line);
|
||||
if (patch) {
|
||||
// applySpecPatch mutates in place — deep-clone so React
|
||||
// never sees mutated objects from a previous render.
|
||||
currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
|
||||
setSpec(currentSpec);
|
||||
} else if (malformed) {
|
||||
// Genuinely malformed JSON (started with { but couldn't parse)
|
||||
malformedLines.push(line.trim());
|
||||
|
||||
if (abortOnMalformed) {
|
||||
await reader.cancel();
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// else: commentary text — skip silently
|
||||
}
|
||||
|
||||
if (aborted) break;
|
||||
}
|
||||
|
||||
// Process any remaining buffer (only if stream completed naturally)
|
||||
if (!aborted && buffer.trim()) {
|
||||
const { patch, malformed } = parsePatchLine(buffer);
|
||||
if (patch) {
|
||||
currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
|
||||
setSpec(currentSpec);
|
||||
} else if (malformed) {
|
||||
malformedLines.push(buffer.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return { spec: currentSpec, completed: !aborted, malformedLines };
|
||||
},
|
||||
[api, fetchFn],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (prompt: string, context?: Record<string, unknown>) => {
|
||||
// Abort any existing request
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
const thisRequestId = ++requestIdRef.current;
|
||||
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
// Start with previous spec if provided, otherwise empty spec
|
||||
const previousSpec = context?.previousSpec as Spec | undefined;
|
||||
let currentSpec: Spec =
|
||||
previousSpec && previousSpec.root
|
||||
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
||||
: { root: "", elements: {} };
|
||||
|
||||
let retriesUsed = 0;
|
||||
let currentPrompt = prompt;
|
||||
let currentContext = context;
|
||||
|
||||
try {
|
||||
// Retry loop handles both mid-stream (malformed JSON) and
|
||||
// post-stream (structural validation) repairs.
|
||||
while (retriesUsed <= maxRetries) {
|
||||
const result = await streamRequest(
|
||||
currentPrompt,
|
||||
currentContext,
|
||||
currentSpec,
|
||||
enableValidation, // only abort on malformed when validation is on
|
||||
controller,
|
||||
);
|
||||
currentSpec = result.spec;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mid-stream repair: stream was aborted due to malformed line
|
||||
// ---------------------------------------------------------------
|
||||
if (!result.completed && result.malformedLines.length > 0) {
|
||||
if (retriesUsed >= maxRetries) {
|
||||
break;
|
||||
}
|
||||
retriesUsed++;
|
||||
|
||||
// Build a repair prompt that asks the AI to continue from
|
||||
// the current partial spec
|
||||
currentContext = { ...context, previousSpec: currentSpec };
|
||||
currentPrompt =
|
||||
`The previous generation contained malformed JSON that could not be parsed. The line was:\n` +
|
||||
`${result.malformedLines[result.malformedLines.length - 1]?.slice(0, 500)}\n\n` +
|
||||
`The current spec state is provided. Continue generating from where you left off. ` +
|
||||
`Output ONLY the remaining patches needed to complete the UI.`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Post-stream: validation is off or spec is empty → done
|
||||
// ---------------------------------------------------------------
|
||||
if (!enableValidation || !currentSpec.root) {
|
||||
break;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Post-stream: auto-fix deterministic issues (no retry needed)
|
||||
// ---------------------------------------------------------------
|
||||
const { spec: fixedSpec, fixes } = autoFixSpec(currentSpec);
|
||||
if (fixes.length > 0) {
|
||||
currentSpec = fixedSpec;
|
||||
setSpec({ ...currentSpec });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Post-stream: structural validation
|
||||
// ---------------------------------------------------------------
|
||||
const validation = validateSpec(currentSpec);
|
||||
if (validation.valid) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Still has errors — check if max retries exhausted
|
||||
if (retriesUsed >= maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
retriesUsed++;
|
||||
const issueText = formatSpecIssues(validation.issues);
|
||||
|
||||
currentContext = { ...context, previousSpec: currentSpec };
|
||||
currentPrompt =
|
||||
`FIX THE FOLLOWING ERRORS in the current UI spec. Output ONLY the patches needed to fix these issues, do not recreate the entire UI.\n\n` +
|
||||
issueText;
|
||||
// continue loop
|
||||
}
|
||||
|
||||
// If retries were exhausted and validation still fails, report error
|
||||
// instead of silently treating partial/invalid specs as complete.
|
||||
if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
|
||||
const finalValidation = validateSpec(currentSpec);
|
||||
if (!finalValidation.valid) {
|
||||
const issueText = formatSpecIssues(finalValidation.issues);
|
||||
const validationError = new Error(
|
||||
`Spec validation failed after ${maxRetries} retries:\n${issueText}`,
|
||||
);
|
||||
setError(validationError);
|
||||
onErrorRef.current?.(validationError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onCompleteRef.current?.(currentSpec);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setError(error);
|
||||
onErrorRef.current?.(error);
|
||||
} finally {
|
||||
// Only the latest request updates isStreaming to avoid the race
|
||||
// where an aborted request's finally clears streaming for the active one.
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[api, fetchFn, enableValidation, maxRetries, streamRequest],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
spec,
|
||||
isStreaming,
|
||||
error,
|
||||
send,
|
||||
stop,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat element list to a Spec.
|
||||
* Input elements use key/parentKey to establish identity and relationships.
|
||||
* Output spec uses the map-based format where key is the map entry key
|
||||
* and parent-child relationships are expressed through children arrays.
|
||||
*/
|
||||
export function flatToTree(elements: FlatElement[]): Spec {
|
||||
const elementMap: Record<string, UIElement> = {};
|
||||
let root = "";
|
||||
|
||||
// First pass: add all elements to map
|
||||
for (const element of elements) {
|
||||
elementMap[element.key] = {
|
||||
type: element.type,
|
||||
props: element.props,
|
||||
children: [],
|
||||
visible: element.visible,
|
||||
};
|
||||
}
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
for (const element of elements) {
|
||||
if (element.parentKey) {
|
||||
const parent = elementMap[element.parentKey];
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
parent.children.push(element.key);
|
||||
} else {
|
||||
console.warn(
|
||||
`[json-render] flatToTree: element "${element.key}" references parent "${element.parentKey}" which does not exist. Element will be orphaned.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (root) {
|
||||
console.warn(
|
||||
`[json-render] flatToTree: multiple root elements found ("${root}" and "${element.key}"). Using "${element.key}" as root.`,
|
||||
);
|
||||
}
|
||||
root = element.key;
|
||||
}
|
||||
}
|
||||
|
||||
return { root, elements: elementMap };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Contexts
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "./contexts/state";
|
||||
|
||||
export {
|
||||
VisibilityProvider,
|
||||
useVisibility,
|
||||
useIsVisible,
|
||||
type VisibilityContextValue,
|
||||
type VisibilityProviderProps,
|
||||
} from "./contexts/visibility";
|
||||
|
||||
export {
|
||||
ActionProvider,
|
||||
useActions,
|
||||
useAction,
|
||||
ConfirmDialog,
|
||||
type ActionContextValue,
|
||||
type ActionProviderProps,
|
||||
type PendingConfirmation,
|
||||
type ConfirmDialogProps,
|
||||
} from "./contexts/actions";
|
||||
|
||||
export {
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
useOptionalValidation,
|
||||
useFieldValidation,
|
||||
type ValidationContextValue,
|
||||
type ValidationProviderProps,
|
||||
type FieldValidationState,
|
||||
} from "./contexts/validation";
|
||||
|
||||
export {
|
||||
RepeatScopeProvider,
|
||||
useRepeatScope,
|
||||
type RepeatScopeValue,
|
||||
} from "./contexts/repeat-scope";
|
||||
|
||||
export { FocusProvider, useFocus, useFocusDisable } from "./contexts/focus";
|
||||
|
||||
// Schema (Ink's spec format)
|
||||
export { schema, type InkSchema, type InkSpec } from "./schema";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
|
||||
// Catalog-aware types for Ink
|
||||
export type {
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "./catalog-types";
|
||||
|
||||
// Renderer
|
||||
export {
|
||||
// Registry
|
||||
defineRegistry,
|
||||
type DefineRegistryResult,
|
||||
// createRenderer (higher-level, includes providers)
|
||||
createRenderer,
|
||||
type CreateRendererProps,
|
||||
type ComponentMap,
|
||||
// Low-level
|
||||
Renderer,
|
||||
JSONUIProvider,
|
||||
type ComponentRenderProps,
|
||||
type ComponentRenderer,
|
||||
type ComponentRegistry,
|
||||
type RendererProps,
|
||||
type JSONUIProviderProps,
|
||||
} from "./renderer";
|
||||
|
||||
// Standard components
|
||||
export { standardComponents } from "./components/standard";
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useUIStream,
|
||||
useBoundProp,
|
||||
flatToTree,
|
||||
type UseUIStreamOptions,
|
||||
type UseUIStreamReturn,
|
||||
} from "./hooks";
|
||||
|
||||
// Catalog definitions
|
||||
export {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
type ComponentDefinition,
|
||||
type ActionDefinition,
|
||||
} from "./catalog";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { Renderer } from "./renderer";
|
||||
|
||||
describe("Renderer", () => {
|
||||
it("renders null for null spec", () => {
|
||||
const element = React.createElement(Renderer, {
|
||||
spec: null,
|
||||
registry: {},
|
||||
});
|
||||
expect(element).toBeDefined();
|
||||
expect(element.props.spec).toBeNull();
|
||||
});
|
||||
|
||||
it("renders null for spec without root", () => {
|
||||
const element = React.createElement(Renderer, {
|
||||
spec: { root: "", elements: {} },
|
||||
registry: {},
|
||||
});
|
||||
expect(element).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts loading prop", () => {
|
||||
const element = React.createElement(Renderer, {
|
||||
spec: null,
|
||||
registry: {},
|
||||
loading: true,
|
||||
});
|
||||
expect(element.props.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts fallback prop", () => {
|
||||
const Fallback = () => React.createElement("div", null, "Unknown");
|
||||
|
||||
const element = React.createElement(Renderer, {
|
||||
spec: null,
|
||||
registry: {},
|
||||
fallback: Fallback,
|
||||
});
|
||||
expect(element.props.fallback).toBe(Fallback);
|
||||
});
|
||||
|
||||
it("merges standard components by default", () => {
|
||||
const element = React.createElement(Renderer, {
|
||||
spec: null,
|
||||
includeStandard: true,
|
||||
});
|
||||
expect(element.props.includeStandard).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,722 @@
|
||||
import React, {
|
||||
type ComponentType,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Text } from "ink";
|
||||
import type {
|
||||
UIElement,
|
||||
Spec,
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
} from "@json-render/core";
|
||||
import type {
|
||||
Components,
|
||||
Actions,
|
||||
SetState,
|
||||
StateModel,
|
||||
} from "./catalog-types";
|
||||
import { useVisibility, VisibilityProvider } from "./contexts/visibility";
|
||||
import { useActions, ActionProvider, ConfirmDialog } from "./contexts/actions";
|
||||
import { useStateStore, StateProvider } from "./contexts/state";
|
||||
import { ValidationProvider } from "./contexts/validation";
|
||||
import { standardComponents } from "./components/standard";
|
||||
import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
|
||||
import { FocusProvider } from "./contexts/focus";
|
||||
|
||||
/**
|
||||
* Props passed to component renderers
|
||||
*/
|
||||
export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||
/** The element being rendered */
|
||||
element: UIElement<string, P>;
|
||||
/** Rendered children */
|
||||
children?: ReactNode;
|
||||
/** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. */
|
||||
emit: (event: string) => void;
|
||||
/**
|
||||
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
|
||||
* Maps prop name → absolute state path for write-back.
|
||||
*/
|
||||
bindings?: Record<string, string>;
|
||||
/** Whether the parent is loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component renderer type
|
||||
*/
|
||||
export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
|
||||
ComponentRenderProps<P>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry of component renderers
|
||||
*/
|
||||
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
|
||||
|
||||
/**
|
||||
* Props for the Renderer component
|
||||
*/
|
||||
export interface RendererProps {
|
||||
/** The UI spec to render */
|
||||
spec: Spec | null;
|
||||
/**
|
||||
* Component registry. If omitted, only standard components are used.
|
||||
* When provided, custom components are merged with (and override) standard components.
|
||||
*/
|
||||
registry?: ComponentRegistry;
|
||||
/** Whether to include standard components (default: true) */
|
||||
includeStandard?: boolean;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementErrorBoundary – catches rendering errors in individual elements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ElementErrorBoundaryProps {
|
||||
elementType: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ElementErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
/** The elementType at the time of the error — used to reset when the type changes. */
|
||||
errorType: string | null;
|
||||
}
|
||||
|
||||
class ElementErrorBoundary extends React.Component<
|
||||
ElementErrorBoundaryProps,
|
||||
ElementErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ElementErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, errorType: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): Partial<ElementErrorBoundaryState> {
|
||||
return { hasError: true, errorType: "__pending__" };
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
props: ElementErrorBoundaryProps,
|
||||
state: ElementErrorBoundaryState,
|
||||
): Partial<ElementErrorBoundaryState> | null {
|
||||
// Reset error state when the element type changes (e.g. spec update replaced
|
||||
// the erroring component). Same-key/same-type errors are still sticky to
|
||||
// prevent infinite error loops; callers can force a reset by changing the key.
|
||||
// "__pending__" means getDerivedStateFromError fired but componentDidCatch
|
||||
// hasn't set the real type yet — don't reset in that window.
|
||||
if (
|
||||
state.hasError &&
|
||||
state.errorType !== null &&
|
||||
state.errorType !== "__pending__" &&
|
||||
state.errorType !== props.elementType
|
||||
) {
|
||||
return { hasError: false, errorType: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.setState({ errorType: this.props.elementType });
|
||||
console.error(
|
||||
`[json-render] Rendering error in <${this.props.elementType}>:`,
|
||||
error,
|
||||
info.componentStack,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return null;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface ElementRendererProps {
|
||||
element: UIElement;
|
||||
spec: Spec;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element renderer component.
|
||||
* Memoized to prevent re-rendering all repeat children when state changes.
|
||||
*/
|
||||
const ElementRenderer = React.memo(function ElementRenderer({
|
||||
element,
|
||||
spec,
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
}: ElementRendererProps) {
|
||||
const repeatScope = useRepeatScope();
|
||||
const { ctx } = useVisibility();
|
||||
const { execute } = useActions();
|
||||
const { getSnapshot } = useStateStore();
|
||||
|
||||
// Build context with repeat scope
|
||||
const fullCtx: PropResolutionContext = useMemo(
|
||||
() =>
|
||||
repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: ctx,
|
||||
[ctx, repeatScope],
|
||||
);
|
||||
|
||||
// Evaluate visibility
|
||||
const isVisible =
|
||||
element.visible === undefined
|
||||
? true
|
||||
: evaluateVisibility(element.visible, fullCtx);
|
||||
|
||||
// Create emit function that resolves events to action bindings.
|
||||
// Errors are caught internally so callers (useInput callbacks) don't
|
||||
// produce unhandled promise rejections.
|
||||
const onBindings = element.on;
|
||||
const emit = useCallback(
|
||||
(eventName: string) => {
|
||||
const binding = onBindings?.[eventName];
|
||||
if (!binding) return;
|
||||
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
||||
(async () => {
|
||||
for (const b of actionBindings) {
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...fullCtx,
|
||||
stateModel: getSnapshot(),
|
||||
};
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(b.params)) {
|
||||
resolved[key] = resolveActionParam(val, liveCtx);
|
||||
}
|
||||
await execute({ ...b, params: resolved });
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error(
|
||||
`[json-render] Error handling event "${eventName}":`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
},
|
||||
[onBindings, execute, fullCtx, getSnapshot],
|
||||
);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve bindings and props
|
||||
const rawProps = element.props as Record<string, unknown>;
|
||||
const elementBindings = resolveBindings(rawProps, fullCtx);
|
||||
const resolvedProps = resolveElementProps(rawProps, fullCtx);
|
||||
|
||||
const resolvedElement =
|
||||
resolvedProps !== element.props
|
||||
? { ...element, props: resolvedProps }
|
||||
: element;
|
||||
|
||||
const Component = registry[resolvedElement.type] ?? fallback;
|
||||
|
||||
if (!Component) {
|
||||
console.warn(
|
||||
`[json-render] No renderer for component type: ${resolvedElement.type}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render children (with repeat support)
|
||||
const children = resolvedElement.repeat ? (
|
||||
<RepeatChildren
|
||||
element={resolvedElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
) : (
|
||||
resolvedElement.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
if (!childElement) {
|
||||
if (!loading) {
|
||||
console.warn(
|
||||
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
|
||||
);
|
||||
return (
|
||||
<Text key={childKey} color="red">{`[Missing: ${childKey}]`}</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ElementRenderer
|
||||
key={childKey}
|
||||
element={childElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ElementErrorBoundary elementType={resolvedElement.type}>
|
||||
<Component
|
||||
element={resolvedElement}
|
||||
emit={emit}
|
||||
bindings={elementBindings}
|
||||
loading={loading}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</ElementErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RepeatChildren
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RepeatChildren({
|
||||
element,
|
||||
spec,
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
}: {
|
||||
element: UIElement;
|
||||
spec: Spec;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
|
||||
const raw = getByPath(state, statePath);
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((itemValue, index) => {
|
||||
const key =
|
||||
repeat.key && typeof itemValue === "object" && itemValue !== null
|
||||
? String(
|
||||
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
|
||||
)
|
||||
: String(index);
|
||||
|
||||
return (
|
||||
<RepeatScopeProvider
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
if (!childElement) {
|
||||
if (!loading) {
|
||||
console.warn(
|
||||
`[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`,
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`${key}:${childKey}`}
|
||||
color="red"
|
||||
>{`[Missing: ${childKey}]`}</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ElementRenderer
|
||||
key={`${key}:${childKey}`}
|
||||
element={childElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RepeatScopeProvider>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main renderer component.
|
||||
*
|
||||
* By default, standard Ink components are included.
|
||||
* Custom components in `registry` override standard ones with the same name.
|
||||
*/
|
||||
export function Renderer({
|
||||
spec,
|
||||
registry: customRegistry,
|
||||
includeStandard = true,
|
||||
loading,
|
||||
fallback,
|
||||
}: RendererProps) {
|
||||
const registry: ComponentRegistry = useMemo(
|
||||
() => ({
|
||||
...(includeStandard ? standardComponents : {}),
|
||||
...customRegistry,
|
||||
}),
|
||||
[customRegistry, includeStandard],
|
||||
);
|
||||
|
||||
if (!spec || !spec.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootElement = spec.elements[spec.root];
|
||||
if (!rootElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ElementRenderer
|
||||
element={rootElement}
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for JSONUIProvider
|
||||
*/
|
||||
export interface JSONUIProviderProps {
|
||||
/**
|
||||
* External store (controlled mode). When provided, `initialState` and
|
||||
* `onStateChange` are ignored.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** Initial state model (uncontrolled mode) */
|
||||
initialState?: Record<string, unknown>;
|
||||
/** Action handlers */
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
/** Navigation function */
|
||||
navigate?: (path: string) => void;
|
||||
/** Custom validation functions */
|
||||
validationFunctions?: Record<
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined provider for all JSONUI contexts
|
||||
*/
|
||||
export function JSONUIProvider({
|
||||
store,
|
||||
initialState,
|
||||
handlers,
|
||||
navigate,
|
||||
validationFunctions,
|
||||
onStateChange,
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
return (
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={initialState}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<FocusProvider>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</FocusProvider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the confirmation dialog when needed
|
||||
*/
|
||||
function ConfirmationDialogManager() {
|
||||
const { pendingConfirmation, confirm, cancel } = useActions();
|
||||
|
||||
if (!pendingConfirmation?.action.confirm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirm={pendingConfirmation.action.confirm}
|
||||
onConfirm={confirm}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// defineRegistry
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result returned by defineRegistry
|
||||
*/
|
||||
export interface DefineRegistryResult {
|
||||
/** Component registry for `<Renderer registry={...} />` */
|
||||
registry: ComponentRegistry;
|
||||
/**
|
||||
* Create ActionProvider-compatible handlers.
|
||||
*/
|
||||
handlers: (
|
||||
getSetState: () => SetState | undefined,
|
||||
getState: () => StateModel,
|
||||
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
|
||||
/**
|
||||
* Execute an action by name imperatively
|
||||
*/
|
||||
executeAction: (
|
||||
actionName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
setState: SetState,
|
||||
state?: StateModel,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a registry from a catalog with components and/or actions.
|
||||
*/
|
||||
export function defineRegistry<C extends Catalog>(
|
||||
_catalog: C,
|
||||
options: {
|
||||
components?: Components<C>;
|
||||
actions?: Actions<C>;
|
||||
},
|
||||
): DefineRegistryResult {
|
||||
const registry: ComponentRegistry = {};
|
||||
if (options.components) {
|
||||
for (const [name, componentFn] of Object.entries(options.components)) {
|
||||
registry[name] = ({
|
||||
element,
|
||||
children,
|
||||
emit,
|
||||
bindings,
|
||||
loading,
|
||||
}: ComponentRenderProps) => {
|
||||
return (componentFn as DefineRegistryComponentFn)({
|
||||
props: element.props,
|
||||
children,
|
||||
emit,
|
||||
bindings,
|
||||
loading,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const actionMap = options.actions
|
||||
? (Object.entries(options.actions) as Array<
|
||||
[string, DefineRegistryActionFn]
|
||||
>)
|
||||
: [];
|
||||
|
||||
const handlers = (
|
||||
getSetState: () => SetState | undefined,
|
||||
getState: () => StateModel,
|
||||
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
|
||||
const result: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<void>
|
||||
> = {};
|
||||
for (const [name, actionFn] of actionMap) {
|
||||
result[name] = async (params) => {
|
||||
const setState = getSetState();
|
||||
const state = getState();
|
||||
if (setState) {
|
||||
await actionFn(params, setState, state);
|
||||
} else {
|
||||
console.warn(
|
||||
`[json-render] Action "${name}" skipped: setState not available. ` +
|
||||
`Ensure the action handler is used within a mounted StateProvider.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const executeAction = async (
|
||||
actionName: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
setState: SetState,
|
||||
state: StateModel = {},
|
||||
): Promise<void> => {
|
||||
const entry = actionMap.find(([name]) => name === actionName);
|
||||
if (entry) {
|
||||
await entry[1](params, setState, state);
|
||||
} else {
|
||||
console.warn(`[json-render] Unknown action: ${actionName}`);
|
||||
}
|
||||
};
|
||||
|
||||
return { registry, handlers, executeAction };
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type DefineRegistryComponentFn = (ctx: {
|
||||
props: unknown;
|
||||
children?: React.ReactNode;
|
||||
emit: (event: string) => void;
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
}) => React.ReactNode;
|
||||
|
||||
/** @internal */
|
||||
type DefineRegistryActionFn = (
|
||||
params: Record<string, unknown> | undefined,
|
||||
setState: SetState,
|
||||
state: StateModel,
|
||||
) => Promise<void>;
|
||||
|
||||
// ============================================================================
|
||||
// createRenderer
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Props for renderers created with createRenderer
|
||||
*/
|
||||
export interface CreateRendererProps {
|
||||
/** The spec to render (AI-generated JSON) */
|
||||
spec: Spec | null;
|
||||
/**
|
||||
* External store (controlled mode).
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** State context for dynamic values (uncontrolled mode) */
|
||||
state?: Record<string, unknown>;
|
||||
/** Action handler */
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component map type
|
||||
*/
|
||||
export type ComponentMap<
|
||||
TComponents extends Record<string, { props: unknown }>,
|
||||
> = {
|
||||
[K in keyof TComponents]: ComponentType<
|
||||
ComponentRenderProps<
|
||||
TComponents[K]["props"] extends { _output: infer O }
|
||||
? O
|
||||
: Record<string, unknown>
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a renderer from a catalog
|
||||
*/
|
||||
export function createRenderer<
|
||||
TDef extends SchemaDefinition,
|
||||
TCatalog extends { components: Record<string, { props: unknown }> },
|
||||
>(
|
||||
_catalog: Catalog<TDef, TCatalog>,
|
||||
components: ComponentMap<TCatalog["components"]>,
|
||||
): ComponentType<CreateRendererProps> {
|
||||
const registry: ComponentRegistry =
|
||||
components as unknown as ComponentRegistry;
|
||||
|
||||
return function CatalogRenderer({
|
||||
spec,
|
||||
store,
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
loading,
|
||||
fallback,
|
||||
}: CreateRendererProps) {
|
||||
const actionHandlers = useMemo(
|
||||
() =>
|
||||
onAction
|
||||
? new Proxy(
|
||||
{} as Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void | Promise<void>
|
||||
>,
|
||||
{
|
||||
get: (_target, prop: string) => {
|
||||
return (params: Record<string, unknown>) =>
|
||||
onAction(prop, params);
|
||||
},
|
||||
has: () => true,
|
||||
},
|
||||
)
|
||||
: undefined,
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<JSONUIProvider
|
||||
store={store}
|
||||
initialState={state}
|
||||
handlers={actionHandlers}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { defineSchema, type Spec } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Ink terminal schema definition.
|
||||
*
|
||||
* Defines the spec shape (what the AI generates) and catalog shape
|
||||
* (what the developer provides as component + action definitions).
|
||||
*/
|
||||
export const schema = defineSchema(
|
||||
(s) => ({
|
||||
// What the AI-generated SPEC looks like
|
||||
spec: s.object({
|
||||
/** Root element key */
|
||||
root: s.string(),
|
||||
/** Flat map of elements by key */
|
||||
elements: s.record(
|
||||
s.object({
|
||||
/** Component type from catalog */
|
||||
type: s.ref("catalog.components"),
|
||||
/** Component props */
|
||||
props: s.propsOf("catalog.components"),
|
||||
/** Child element keys (flat reference) */
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: s.any(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
// What the CATALOG must provide
|
||||
catalog: s.object({
|
||||
/** Component definitions */
|
||||
components: s.map({
|
||||
/** Zod schema for component props */
|
||||
props: s.zod(),
|
||||
/** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
|
||||
slots: s.array(s.string()),
|
||||
/** Description for AI generation hints */
|
||||
description: s.string(),
|
||||
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
|
||||
example: s.any(),
|
||||
}),
|
||||
/** Action definitions (optional) */
|
||||
actions: s.map({
|
||||
/** Zod schema for action params */
|
||||
params: s.zod(),
|
||||
/** Description for AI generation hints */
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
builtInActions: [
|
||||
{
|
||||
name: "setState",
|
||||
description:
|
||||
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
|
||||
},
|
||||
{
|
||||
name: "pushState",
|
||||
description:
|
||||
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
|
||||
},
|
||||
{
|
||||
name: "removeState",
|
||||
description:
|
||||
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
// Element integrity
|
||||
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
|
||||
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
|
||||
// Field placement
|
||||
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
|
||||
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. logs, tasks, metrics), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array, use the "repeat" field on a container element. Example: { "type": "Box", "props": { "flexDirection": "column" }, "repeat": { "statePath": "/items", "key": "id" }, "children": ["item-row"] }. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index.',
|
||||
// Terminal UI design
|
||||
"This UI renders in a terminal using Ink. Use Box for layout (flexDirection, padding, gap), Text for text content. Keep designs compact and readable in monospace.",
|
||||
"Terminal UIs have limited width (~80-120 columns). Prefer vertical layouts (flexDirection: column) for main structure. Use horizontal layouts (flexDirection: row) for inline elements like badges, key-value pairs, and table rows.",
|
||||
"Use borderStyle on Box for visual grouping (single, double, round, bold). Use padding sparingly — 1 unit is usually enough.",
|
||||
"For color, use named terminal colors: red, green, yellow, blue, magenta, cyan, white, gray. Use hex colors sparingly.",
|
||||
"Always include realistic, professional-looking sample data. For lists include 3-5 items with varied content. Never leave data empty.",
|
||||
"Use Heading for section titles, Divider to separate sections, Badge for status indicators, KeyValue for labeled data, and Card for bordered groups.",
|
||||
"Use Tabs for multi-view UIs — bind the active tab to state and use visible conditions on child content to show/hide tab panels. Use MultiSelect for picking multiple items. Use ConfirmInput for yes/no prompts before destructive actions.",
|
||||
"Use Sparkline for inline trend visualization (compact, one line). Use BarChart for comparing values across categories (horizontal bars with labels). Both work well in dashboards alongside KeyValue and ProgressBar.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Type alias for the Ink schema
|
||||
*/
|
||||
export type InkSchema = typeof schema;
|
||||
|
||||
/**
|
||||
* Spec type for Ink (parameterized by catalog)
|
||||
*/
|
||||
export type InkSpec = Spec;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Server-safe entry point: schema and catalog definitions only.
|
||||
// Uses `import type` for React types (erased at runtime — no React dependency).
|
||||
|
||||
export { schema, type InkSchema, type InkSpec } from "./schema";
|
||||
|
||||
export {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
type ComponentDefinition,
|
||||
type ActionDefinition,
|
||||
} from "./catalog";
|
||||
|
||||
export type { Spec } from "@json-render/core";
|
||||
|
||||
export type {
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "./catalog-types";
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts", "src/server.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "ink", "@json-render/core", "zod"],
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/mcp
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/mcp",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-email
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-email",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.14.1",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
|
||||
## 0.8.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user