Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions docs/content/docs/api-reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,15 @@ openui generate [entry] [options]

**Options**

| Flag | Description |
| ------------------------- | -------------------------------------------------------------------- |
| `-o, --out <file>` | Write output to a file instead of stdout |
| `--json-schema` | Output JSON schema instead of a system prompt |
| `--export <name>` | Name of the export to use (auto-detected by default) |
| `--prompt-options <name>` | Name of the `PromptOptions` export to use (auto-detected by default) |
| `--no-interactive` | Fail instead of prompting for missing `entry` |
| Flag | Description |
| ------------------------------ | ---------------------------------------------------------------------------- |
| `-o, --out <file>` | Write output to a file instead of stdout |
| `--json-schema` | Output JSON schema instead of a system prompt |
| `--export <name>` | Name of the export to use (auto-detected by default) |
| `--prompt-options <name>` | Name of the `PromptOptions` export to use (auto-detected by default) |
| `--component-allowlist <list>` | Comma-separated allow-list of components to include in the prompt |
| `--no-include-dependencies` | With `--component-allowlist`, do not auto-include sub-component dependencies |
| `--no-interactive` | Fail instead of prompting for missing `entry` |

**Examples**

Expand All @@ -106,6 +108,12 @@ openui generate ./src/library.ts --json-schema

# Explicit export names
openui generate ./src/library.ts --export myLibrary --prompt-options myOptions

# Only include a subset of components (plus root and their sub-component deps)
openui generate ./src/library.ts --component-allowlist Card,Table,LineChart

# Subset with exactly the listed components (no auto-included dependencies)
openui generate ./src/library.ts --component-allowlist Card,Table --no-include-dependencies
```

### Export auto-detection
Expand Down Expand Up @@ -140,6 +148,13 @@ interface PromptOptions {
toolCalls?: boolean;
/** Enable $variables, @Set, @Reset, built-in functions. Default: true if toolCalls. */
bindings?: boolean;
/** Restrict the prompt to a subset of the library. Omit for all components. */
componentAllowlist?: {
/** Component names to keep. Root + sub-component deps are kept automatically. */
components: string[];
/** Auto-include sub-component dependencies of the listed components. Default: true. */
includeDependencies?: boolean;
};
}
```

Expand Down
24 changes: 24 additions & 0 deletions docs/content/docs/openui-lang/system-prompts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ const systemPrompt = openuiLibrary.prompt(openuiPromptOptions);

This is convenient but imports React components - use `generatePrompt` for pure backend routes.

### Subsetting the library (`componentAllowlist`)

By default the prompt describes **every** component in the library. For a large library that can be ~20k tokens even when a given screen only needs a handful. Pass a `componentAllowlist` to include only what you need:

```ts
// Only Card, Table, and LineChart (plus the root and their sub-components)
const systemPrompt = openuiLibrary.prompt({
componentAllowlist: { components: ["Card", "Table", "LineChart"] },
});
```

- **`componentAllowlist.components: string[]`** — when set, only these components appear. Omit `componentAllowlist` for today's behavior (all components). Fully backward-compatible.
- The library **root** is always kept so the output stays self-consistent.
- **Sub-component dependencies are pulled in automatically** — if a listed component renders other components as children, those are included too, so the prompt never references a signature it doesn't show. Pass `componentAllowlist.includeDependencies: false` to opt out and emit _exactly_ the listed components (plus root).
- `componentGroups` are filtered to the kept set; empty groups are dropped.
- Unknown names throw with an "Available components: ..." message.

This is also available on the CLI:

```bash
openui generate ./lib.tsx --component-allowlist Card,Table,LineChart
openui generate ./lib.tsx --component-allowlist Card,Table --no-include-dependencies
```

## What gets generated

The generated prompt includes:
Expand Down
92 changes: 92 additions & 0 deletions packages/lang-core/src/__tests__/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,98 @@ describe("getSchemaId fallback", () => {
});
});

// ─── component allow-list (PromptOptions.componentAllowlist) ─────────────────

describe("prompt({ componentAllowlist })", () => {
function buildLib() {
const Stat = defineComponent({
name: "Stat",
props: z.object({ label: z.string(), value: z.string() }),
description: "A stat",
component: Dummy,
});
const Row = defineComponent({
name: "Row",
props: z.object({ cells: z.array(z.string()) }),
description: "A row",
component: Dummy,
});
// Table renders Row children — a sub-component dependency
const Table = defineComponent({
name: "Table",
props: z.object({ rows: z.array(Row.ref) }),
description: "A table",
component: Dummy,
});
const Card = defineComponent({
name: "Card",
props: z.object({ title: z.string() }),
description: "A card",
component: Dummy,
});
// Page is the root and renders any of the above
const Page = defineComponent({
name: "Page",
props: z.object({ children: z.array(z.union([Card.ref, Table.ref, Stat.ref])) }),
description: "The page root",
component: Dummy,
});
return createLibrary({
components: [Page, Card, Table, Row, Stat],
componentGroups: [
{ name: "Data", components: ["Table", "Stat"] },
{ name: "Layout", components: ["Card"] },
],
root: "Page",
});
}

it("includes only listed components (plus root)", () => {
const prompt = buildLib().prompt({ componentAllowlist: { components: ["Card", "Stat"] } });
expect(prompt).toContain("Card(");
expect(prompt).toContain("Stat(");
expect(prompt).toContain("Page("); // root always kept
expect(prompt).not.toContain("Table(");
});

it("auto-includes sub-component dependencies", () => {
const prompt = buildLib().prompt({ componentAllowlist: { components: ["Table"] } });
expect(prompt).toContain("Table(");
expect(prompt).toContain("Row("); // Table renders Row → pulled in
expect(prompt).not.toContain("Stat(");
});

it("includeDependencies: false drops the dependency closure", () => {
const prompt = buildLib().prompt({
componentAllowlist: { components: ["Table"], includeDependencies: false },
});
expect(prompt).toContain("Table(");
expect(prompt).not.toContain("Row(");
});

it("filters componentGroups to the kept set and drops empty groups", () => {
const prompt = buildLib().prompt({ componentAllowlist: { components: ["Stat"] } });
expect(prompt).toContain("Data"); // group still has Stat
expect(prompt).not.toContain("Layout"); // group's only member (Card) was dropped
});

it("throws on unknown component names with an Available list", () => {
expect(() => buildLib().prompt({ componentAllowlist: { components: ["Nope"] } })).toThrow(
/Available components:/,
);
expect(() => buildLib().prompt({ componentAllowlist: { components: ["Nope"] } })).toThrow(
/Nope/,
);
});

it("omitting componentAllowlist keeps today's behavior (all components)", () => {
const prompt = buildLib().prompt();
for (const name of ["Page", "Card", "Table", "Row", "Stat"]) {
expect(prompt).toContain(`${name}(`);
}
});
});

// ─── assertV4Schema ─────────────────────────────────────────────────────────

describe("assertV4Schema", () => {
Expand Down
154 changes: 151 additions & 3 deletions packages/lang-core/src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ export interface PromptOptions {
toolCalls?: boolean;
/** Enable $variables, @Set, @Reset, interactive filters. Default: true if toolCalls. */
bindings?: boolean;
/**
* Restrict the prompt to a subset of the library. When set, only these
* components appear (plus the library root and, by default, the sub-component
* dependencies they render). Omit for today's behavior (all components).
*/
componentAllowlist?: {
/** Component names to keep. Unknown names throw with an "Available: ..." message. */
components: string[];
/**
* Automatically pull in the sub-components that the listed components
* reference, so the prompt never shows a signature that references a
* component it doesn't include. Default: true.
*/
includeDependencies?: boolean;
};
}

// ─── Zod introspection ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -330,6 +345,116 @@ function buildComponentSpecs(
return specs;
}

// ─── Component subsetting ─────────────────────────────────────────────────────

/**
* Walk a single field schema and collect the names of any tagged schemas it
* references (component refs, e.g. `z.array(Card.ref)`). Descends through
* optional/array/union/record/anonymous-object wrappers but stops at the first
* named schema — its own dependencies are resolved separately during closure.
*/
function collectSchemaRefs(
schema: unknown,
reg: SchemaRegistry,
acc: Set<string>,
seen: Set<unknown>,
): void {
const inner = unwrap(schema);
if (inner == null || seen.has(inner)) return;
seen.add(inner);

const id = getSchemaId(inner, reg);
if (id) {
acc.add(id);
return; // named ref — descend no further; its deps are handled via the closure
}

const unionOpts = getUnionOptions(inner);
if (unionOpts) {
for (const opt of unionOpts) collectSchemaRefs(opt, reg, acc, seen);
return;
}

if (isArrayType(inner)) {
const el = getArrayInnerType(inner);
if (el) collectSchemaRefs(el, reg, acc, seen);
return;
}

const def = getZodDef(inner);
if (def?.type === "record") {
collectSchemaRefs(def.keyType, reg, acc, seen);
collectSchemaRefs(def.valueType, reg, acc, seen);
return;
}

const shape = getObjectShape(inner);
if (shape) {
for (const field of Object.values(shape)) collectSchemaRefs(field, reg, acc, seen);
}
}

/** Component names directly referenced by a component's props (one hop). */
function directDependencies(comp: DefinedComponent<any, any>, reg: SchemaRegistry): Set<string> {
const acc = new Set<string>();
const shape = getObjectShape(comp.props);
if (shape) {
for (const field of Object.values(shape)) collectSchemaRefs(field, reg, acc, new Set());
}
return acc;
}

/**
* Resolve the set of component names to keep for a prompt subset: the requested
* allow-list, always the root, and (unless disabled) the transitive closure of
* sub-component dependencies. Unknown requested names throw.
*/
function resolveComponentSubset(
components: Record<string, DefinedComponent<any, any>>,
reg: SchemaRegistry,
requested: string[],
root: string | undefined,
includeDependencies: boolean,
): Set<string> {
for (const name of requested) {
if (!components[name]) {
const available = Object.keys(components).join(", ");
throw new Error(
`[prompt] Component "${name}" was not found in components. Available components: ${available}`,
);
}
}

const kept = new Set<string>(requested);

// Walk the dependency closure of the *listed* components only. The root is
// intentionally not a seed here: roots typically render every component as a
// child, so expanding the root's deps would pull the whole library back in
// and defeat the subset.
if (includeDependencies) {
const queue = [...requested];
while (queue.length > 0) {
const name = queue.shift() as string;
const comp = components[name];
if (!comp) continue;
for (const dep of directDependencies(comp, reg)) {
// Only library components are kept; non-component tags (e.g. ActionExpression)
// already resolve in signatures and don't need to be in the kept set.
if (components[dep] && !kept.has(dep)) {
kept.add(dep);
queue.push(dep);
}
}
}
}

// The root is always kept so the prompt stays self-consistent, but its own
// dependencies are not expanded (see above).
if (root) kept.add(root);

return kept;
}

// ─── Library ────────────────────────────────────────────────────────────────

export interface Library<C = unknown> {
Expand Down Expand Up @@ -373,11 +498,34 @@ export function createLibrary<C = unknown>(input: LibraryDefinition<C>): Library
root: input.root,

prompt(options?: PromptOptions): string {
// `componentAllowlist` is a subset control, not a prompt field — pull it
// out so it never leaks into the PromptSpec via the spread below.
const { componentAllowlist, ...promptFields } = options ?? {};

let activeComponents = componentsRecord;
let activeGroups = input.componentGroups;

if (componentAllowlist) {
const kept = resolveComponentSubset(
componentsRecord,
reg,
componentAllowlist.components,
input.root,
componentAllowlist.includeDependencies !== false,
);
activeComponents = Object.fromEntries(
Object.entries(componentsRecord).filter(([name]) => kept.has(name)),
);
activeGroups = input.componentGroups
?.map((g) => ({ ...g, components: g.components.filter((c) => kept.has(c)) }))
.filter((g) => g.components.length > 0);
}

const spec: PromptSpec = {
root: input.root,
components: buildComponentSpecs(componentsRecord, reg),
componentGroups: input.componentGroups,
...options,
components: buildComponentSpecs(activeComponents, reg),
componentGroups: activeGroups,
...promptFields,
};
return generatePrompt(spec);
},
Expand Down
Loading
Loading