diff --git a/docs/content/docs/api-reference/cli.mdx b/docs/content/docs/api-reference/cli.mdx index 25d9c6f9b..55544e613 100644 --- a/docs/content/docs/api-reference/cli.mdx +++ b/docs/content/docs/api-reference/cli.mdx @@ -84,13 +84,15 @@ openui generate [entry] [options] **Options** -| Flag | Description | -| ------------------------- | -------------------------------------------------------------------- | -| `-o, --out ` | Write output to a file instead of stdout | -| `--json-schema` | Output JSON schema instead of a system prompt | -| `--export ` | Name of the export to use (auto-detected by default) | -| `--prompt-options ` | Name of the `PromptOptions` export to use (auto-detected by default) | -| `--no-interactive` | Fail instead of prompting for missing `entry` | +| Flag | Description | +| ------------------------------ | ---------------------------------------------------------------------------- | +| `-o, --out ` | Write output to a file instead of stdout | +| `--json-schema` | Output JSON schema instead of a system prompt | +| `--export ` | Name of the export to use (auto-detected by default) | +| `--prompt-options ` | Name of the `PromptOptions` export to use (auto-detected by default) | +| `--component-allowlist ` | 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** @@ -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 @@ -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; + }; } ``` diff --git a/docs/content/docs/openui-lang/system-prompts.mdx b/docs/content/docs/openui-lang/system-prompts.mdx index e2253eb3b..3081a923b 100644 --- a/docs/content/docs/openui-lang/system-prompts.mdx +++ b/docs/content/docs/openui-lang/system-prompts.mdx @@ -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: diff --git a/packages/lang-core/src/__tests__/library.test.ts b/packages/lang-core/src/__tests__/library.test.ts index 16e0b7e58..ddcdf960e 100644 --- a/packages/lang-core/src/__tests__/library.test.ts +++ b/packages/lang-core/src/__tests__/library.test.ts @@ -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", () => { diff --git a/packages/lang-core/src/library.ts b/packages/lang-core/src/library.ts index f998bc5bf..85f160805 100644 --- a/packages/lang-core/src/library.ts +++ b/packages/lang-core/src/library.ts @@ -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 ────────────────────────────────────────────────────── @@ -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, + seen: Set, +): 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, reg: SchemaRegistry): Set { + const acc = new Set(); + 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>, + reg: SchemaRegistry, + requested: string[], + root: string | undefined, + includeDependencies: boolean, +): Set { + 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(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 { @@ -373,11 +498,34 @@ export function createLibrary(input: LibraryDefinition): 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); }, diff --git a/packages/openui-cli/src/commands/generate-worker.ts b/packages/openui-cli/src/commands/generate-worker.ts index 6ae1a9cc5..cd4c119cd 100644 --- a/packages/openui-cli/src/commands/generate-worker.ts +++ b/packages/openui-cli/src/commands/generate-worker.ts @@ -3,7 +3,8 @@ * prompt or JSON schema. Asset imports are stubbed during bundling so React * component modules can be evaluated without CSS/image/font loaders. * - * argv: [entryPath, exportName?, "--json-schema"?, "--prompt-options", name?] + * argv: [entryPath, exportName?, "--json-schema"?, "--prompt-options", name?, + * "--component-allowlist", "Card,Table", "--no-include-dependencies"?] * stdout: the prompt string or JSON schema */ @@ -80,6 +81,7 @@ interface PromptOptions { inlineMode?: boolean; toolCalls?: boolean; bindings?: boolean; + componentAllowlist?: { components: string[]; includeDependencies?: boolean }; } function isPromptOptions(value: unknown): value is PromptOptions { @@ -130,12 +132,24 @@ async function main(): Promise { } const jsonSchema = args.includes("--json-schema"); + const noIncludeDependencies = args.includes("--no-include-dependencies"); const promptOptionsIdx = args.indexOf("--prompt-options"); const promptOptionsName = promptOptionsIdx !== -1 ? args[promptOptionsIdx + 1] : undefined; - const reserved = new Set(["--json-schema", "--prompt-options"]); + const allowlistIdx = args.indexOf("--component-allowlist"); + const allowlistArg = allowlistIdx !== -1 ? args[allowlistIdx + 1] : undefined; + const reserved = new Set([ + "--json-schema", + "--no-include-dependencies", + "--prompt-options", + "--component-allowlist", + ]); if (promptOptionsName) reserved.add(promptOptionsName); + if (allowlistArg) reserved.add(allowlistArg); const exportName = args.find( - (a, i) => a !== entryPath && !reserved.has(a) && !(i > 0 && args[i - 1] === "--prompt-options"), + (a, i) => + a !== entryPath && + !reserved.has(a) && + !(i > 0 && (args[i - 1] === "--prompt-options" || args[i - 1] === "--component-allowlist")), ); const bundleDir = fs.mkdtempSync(path.join(os.tmpdir(), "openui-generate-")); @@ -188,7 +202,23 @@ async function main(): Promise { output = JSON.stringify(library.toSpec(), null, 2); } else { const promptOptions = findPromptOptions(mod, promptOptionsName); - output = library.prompt(promptOptions); + const finalOptions: PromptOptions = { ...(promptOptions ?? {}) }; + if (allowlistArg !== undefined) { + finalOptions.componentAllowlist = { + components: allowlistArg + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ...(noIncludeDependencies ? { includeDependencies: false } : {}), + }; + } else if (noIncludeDependencies && finalOptions.componentAllowlist) { + // --no-include-dependencies on its own refines an allowlist from the module's PromptOptions. + finalOptions.componentAllowlist = { + ...finalOptions.componentAllowlist, + includeDependencies: false, + }; + } + output = library.prompt(finalOptions); } process.stdout.write(output); diff --git a/packages/openui-cli/src/commands/generate.ts b/packages/openui-cli/src/commands/generate.ts index f6dab897b..2521af3b0 100644 --- a/packages/openui-cli/src/commands/generate.ts +++ b/packages/openui-cli/src/commands/generate.ts @@ -7,6 +7,10 @@ export interface GenerateOptions { jsonSchema?: boolean; export?: string; promptOptions?: string; + /** Comma-separated allow-list of components to include in the prompt. */ + componentAllowlist?: string; + /** When false, skip auto-including sub-component dependencies of `--component-allowlist`. */ + includeDependencies?: boolean; } export async function runGenerate(entry: string, options: GenerateOptions): Promise { @@ -23,6 +27,9 @@ export async function runGenerate(entry: string, options: GenerateOptions): Prom if (options.export) workerArgs.push(options.export); if (options.jsonSchema) workerArgs.push("--json-schema"); if (options.promptOptions) workerArgs.push("--prompt-options", options.promptOptions); + if (options.componentAllowlist) + workerArgs.push("--component-allowlist", options.componentAllowlist); + if (options.includeDependencies === false) workerArgs.push("--no-include-dependencies"); let output: string; try { diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index 43a2140c5..b6ec0fcfd 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -39,6 +39,14 @@ program "--prompt-options ", "Name of the PromptOptions export to use (auto-detected by default)", ) + .option( + "--component-allowlist ", + "Comma-separated allow-list of components to include in the prompt (e.g. Card,Table,Stat)", + ) + .option( + "--no-include-dependencies", + "Do not auto-include sub-component dependencies of --component-allowlist", + ) .option("--no-interactive", "Fail with error if required args are missing") .action( async ( @@ -48,6 +56,8 @@ program jsonSchema?: boolean; export?: string; promptOptions?: string; + componentAllowlist?: string; + includeDependencies?: boolean; interactive: boolean; }, ) => {