Skip to main content

everruns_openui/
prompt.rs

1//! OpenUI System Prompt Generator
2//!
3//! Generates the system prompt that instructs LLMs to respond in OpenUI Lang.
4//! This is a Rust reimplementation of the TypeScript prompt generator.
5//!
6//! Ref: packages/react-lang/src/parser/prompt.ts
7
8use crate::components::{ComponentDef, Library};
9
10/// Options for customizing the generated prompt.
11///
12/// Ref: packages/react-lang/src/library.ts `PromptOptions` interface
13#[derive(Default)]
14pub struct PromptOptions {
15    /// Custom preamble text (replaces the default)
16    pub preamble: Option<String>,
17    /// Additional rules appended to the prompt
18    pub additional_rules: Vec<String>,
19    /// Example OpenUI Lang code blocks
20    pub examples: Vec<String>,
21}
22
23/// Default preamble for the system prompt.
24///
25/// Modified from the upstream version to instruct wrapping in ```openui code blocks.
26///
27/// Ref: packages/react-lang/src/parser/prompt.ts `PREAMBLE`
28const PREAMBLE: &str = "You are an AI assistant that can generate rich interactive UI using openui-lang, a declarative UI language. When the user asks for visual content (dashboards, charts, tables, forms, lists, etc.), respond with openui-lang code wrapped in a ```openui fenced code block. You may include explanatory markdown text before and/or after the code block.";
29
30/// Generate the syntax rules section.
31///
32/// Ref: packages/react-lang/src/parser/prompt.ts `syntaxRules()`
33fn syntax_rules(root_name: &str) -> String {
34    format!(
35        r#"## Syntax Rules
361. Each statement is on its own line: `identifier = Expression`
372. `root` is the entry point — every program must define `root = {root_name}(...)`
383. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({{...}}), or component calls TypeName(arg1, arg2, ...)
394. Use references for readability: define `name = ...` on one line, then use `name` later
405. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render.
416. Arguments are POSITIONAL (order matters, not names)
427. Optional arguments can be omitted from the end
438. No operators, no logic, no variables — only declarations
449. Strings use double quotes with backslash escaping"#
45    )
46}
47
48/// Generate the streaming/hoisting rules section.
49///
50/// Ref: packages/react-lang/src/parser/prompt.ts `streamingRules()`
51fn streaming_rules(root_name: &str) -> String {
52    format!(
53        r#"## Hoisting & Streaming (CRITICAL)
54openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed.
55During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in.
56**Recommended statement order for optimal streaming:**
571. `root = {root_name}(...)` — UI shell appears immediately
582. Component definitions — fill in as they stream
593. Data values — leaf content last
60Always write the root = {root_name}(...) statement first so the UI shell appears immediately."#
61    )
62}
63
64/// Generate the important rules section.
65///
66/// Ref: packages/react-lang/src/parser/prompt.ts `importantRules()`
67fn important_rules(root_name: &str) -> String {
68    format!(
69        r#"## Important Rules
70- ALWAYS start with root = {root_name}(...)
71- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming)
72- Each statement on its own line
73- When asked about data, generate realistic/plausible data
74- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.)
75- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root.
76- Wrap your openui-lang code in a ```openui fenced code block
77- You may include explanatory text before or after the code block"#
78    )
79}
80
81/// Build the signature line for a single component.
82///
83/// Produces output like: `Stack(children: any[], direction?: "row" | "column", ...) — Flex container.`
84///
85/// Ref: packages/react-lang/src/parser/prompt.ts `buildComponentLine()`
86fn build_component_line(component: &ComponentDef) -> String {
87    let params: Vec<String> = component
88        .props
89        .iter()
90        .map(|p| {
91            if p.optional {
92                format!("{}?: {}", p.name, p.type_annotation)
93            } else {
94                format!("{}: {}", p.name, p.type_annotation)
95            }
96        })
97        .collect();
98
99    let sig = format!("{}({})", component.name, params.join(", "));
100
101    if component.description.is_empty() {
102        sig
103    } else {
104        format!("{} — {}", sig, component.description)
105    }
106}
107
108/// Generate the component signatures section.
109///
110/// Ref: packages/react-lang/src/parser/prompt.ts `generateComponentSignatures()`
111fn generate_component_signatures(library: &Library) -> String {
112    let mut lines = vec![
113        "## Component Signatures".to_string(),
114        String::new(),
115        "Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.".to_string(),
116    ];
117
118    if library.groups.is_empty() {
119        // Ungrouped — list all components
120        lines.push(String::new());
121        for comp in &library.components {
122            lines.push(build_component_line(comp));
123        }
124    } else {
125        // Build a lookup map for components by name
126        let comp_map: std::collections::HashMap<&str, &ComponentDef> =
127            library.components.iter().map(|c| (c.name, c)).collect();
128
129        let mut grouped_names = std::collections::HashSet::new();
130
131        for group in &library.groups {
132            lines.push(String::new());
133            lines.push(format!("### {}", group.name));
134
135            for name in &group.components {
136                if grouped_names.contains(name) {
137                    continue;
138                }
139                if let Some(comp) = comp_map.get(name) {
140                    grouped_names.insert(*name);
141                    lines.push(build_component_line(comp));
142                }
143            }
144
145            for note in &group.notes {
146                lines.push(note.to_string());
147            }
148        }
149
150        // Any ungrouped components
151        let ungrouped: Vec<&ComponentDef> = library
152            .components
153            .iter()
154            .filter(|c| !grouped_names.contains(c.name))
155            .collect();
156
157        if !ungrouped.is_empty() {
158            lines.push(String::new());
159            lines.push("### Ungrouped".to_string());
160            for comp in ungrouped {
161                lines.push(build_component_line(comp));
162            }
163        }
164    }
165
166    lines.join("\n")
167}
168
169/// Generate the complete OpenUI system prompt.
170///
171/// This is the main entry point. Produces a prompt that, when prepended to the
172/// system prompt, instructs the LLM to respond with OpenUI Lang code.
173///
174/// Ref: packages/react-lang/src/parser/prompt.ts `generatePrompt()`
175#[allow(clippy::vec_init_then_push)]
176pub fn generate_prompt(library: &Library, options: &PromptOptions) -> String {
177    let root_name = library.root;
178    let mut parts = Vec::new();
179
180    // 1. Preamble
181    parts.push(options.preamble.as_deref().unwrap_or(PREAMBLE).to_string());
182    parts.push(String::new());
183
184    // 2. Syntax rules
185    parts.push(syntax_rules(root_name));
186    parts.push(String::new());
187
188    // 3. Component signatures
189    parts.push(generate_component_signatures(library));
190    parts.push(String::new());
191
192    // 4. Streaming/hoisting rules
193    parts.push(streaming_rules(root_name));
194
195    // 5. Optional examples
196    if !options.examples.is_empty() {
197        parts.push(String::new());
198        parts.push("## Examples".to_string());
199        parts.push(String::new());
200        for ex in &options.examples {
201            parts.push(ex.clone());
202            parts.push(String::new());
203        }
204    }
205
206    // 6. Important rules
207    parts.push(important_rules(root_name));
208
209    // 7. Additional rules
210    if !options.additional_rules.is_empty() {
211        parts.push(String::new());
212        for rule in &options.additional_rules {
213            parts.push(format!("- {rule}"));
214        }
215    }
216
217    parts.join("\n")
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::components::PropDef;
224
225    fn test_library() -> Library {
226        Library {
227            root: "Stack",
228            components: vec![
229                ComponentDef {
230                    name: "Stack",
231                    props: &[PropDef {
232                        name: "children",
233                        type_annotation: "any[]",
234                        optional: false,
235                    }],
236                    description: "Flex container",
237                },
238                ComponentDef {
239                    name: "TextContent",
240                    props: &[
241                        PropDef {
242                            name: "text",
243                            type_annotation: "string",
244                            optional: false,
245                        },
246                        PropDef {
247                            name: "size",
248                            type_annotation: "\"small\" | \"large\"",
249                            optional: true,
250                        },
251                    ],
252                    description: "Text block",
253                },
254            ],
255            groups: vec![],
256        }
257    }
258
259    #[test]
260    fn test_generate_prompt_contains_preamble() {
261        let lib = test_library();
262        let prompt = generate_prompt(&lib, &PromptOptions::default());
263        assert!(prompt.contains("openui-lang"));
264        assert!(prompt.contains("```openui"));
265    }
266
267    #[test]
268    fn test_generate_prompt_contains_syntax_rules() {
269        let lib = test_library();
270        let prompt = generate_prompt(&lib, &PromptOptions::default());
271        assert!(prompt.contains("## Syntax Rules"));
272        assert!(prompt.contains("root = Stack(...)"));
273        assert!(prompt.contains("POSITIONAL"));
274    }
275
276    #[test]
277    fn test_generate_prompt_contains_component_signatures() {
278        let lib = test_library();
279        let prompt = generate_prompt(&lib, &PromptOptions::default());
280        assert!(prompt.contains("## Component Signatures"));
281        assert!(prompt.contains("Stack(children: any[]) — Flex container"));
282        assert!(
283            prompt.contains("TextContent(text: string, size?: \"small\" | \"large\") — Text block")
284        );
285    }
286
287    #[test]
288    fn test_generate_prompt_contains_streaming_rules() {
289        let lib = test_library();
290        let prompt = generate_prompt(&lib, &PromptOptions::default());
291        assert!(prompt.contains("## Hoisting & Streaming"));
292        assert!(prompt.contains("hoisting"));
293    }
294
295    #[test]
296    fn test_generate_prompt_contains_important_rules() {
297        let lib = test_library();
298        let prompt = generate_prompt(&lib, &PromptOptions::default());
299        assert!(prompt.contains("## Important Rules"));
300        assert!(prompt.contains("ALWAYS start with root = Stack(...)"));
301    }
302
303    #[test]
304    fn test_custom_preamble() {
305        let lib = test_library();
306        let options = PromptOptions {
307            preamble: Some("Custom preamble here".to_string()),
308            ..Default::default()
309        };
310        let prompt = generate_prompt(&lib, &options);
311        assert!(prompt.contains("Custom preamble here"));
312        assert!(!prompt.contains("You are an AI assistant"));
313    }
314
315    #[test]
316    fn test_additional_rules() {
317        let lib = test_library();
318        let options = PromptOptions {
319            additional_rules: vec!["Always use dark theme".to_string()],
320            ..Default::default()
321        };
322        let prompt = generate_prompt(&lib, &options);
323        assert!(prompt.contains("- Always use dark theme"));
324    }
325
326    #[test]
327    fn test_examples() {
328        let lib = test_library();
329        let options = PromptOptions {
330            examples: vec!["root = Stack([text])\ntext = TextContent(\"Hello\")".to_string()],
331            ..Default::default()
332        };
333        let prompt = generate_prompt(&lib, &options);
334        assert!(prompt.contains("## Examples"));
335        assert!(prompt.contains("root = Stack([text])"));
336    }
337
338    #[test]
339    fn test_grouped_components() {
340        use crate::groups::ComponentGroup;
341
342        let lib = Library {
343            root: "Stack",
344            components: vec![
345                ComponentDef {
346                    name: "Stack",
347                    props: &[PropDef {
348                        name: "children",
349                        type_annotation: "any[]",
350                        optional: false,
351                    }],
352                    description: "Flex container",
353                },
354                ComponentDef {
355                    name: "TextContent",
356                    props: &[PropDef {
357                        name: "text",
358                        type_annotation: "string",
359                        optional: false,
360                    }],
361                    description: "Text block",
362                },
363            ],
364            groups: vec![
365                ComponentGroup {
366                    name: "Layout",
367                    components: vec!["Stack"],
368                    notes: vec![],
369                },
370                ComponentGroup {
371                    name: "Content",
372                    components: vec!["TextContent"],
373                    notes: vec!["Use TextContent for plain text."],
374                },
375            ],
376        };
377
378        let prompt = generate_prompt(&lib, &PromptOptions::default());
379        assert!(prompt.contains("### Layout"));
380        assert!(prompt.contains("### Content"));
381        assert!(prompt.contains("Use TextContent for plain text."));
382    }
383
384    #[test]
385    fn test_build_component_line_required_only() {
386        let comp = ComponentDef {
387            name: "Table",
388            props: &[PropDef {
389                name: "data",
390                type_annotation: "any[]",
391                optional: false,
392            }],
393            description: "Data table",
394        };
395        let line = build_component_line(&comp);
396        assert_eq!(line, "Table(data: any[]) — Data table");
397    }
398
399    #[test]
400    fn test_build_component_line_mixed_props() {
401        let comp = ComponentDef {
402            name: "Button",
403            props: &[
404                PropDef {
405                    name: "label",
406                    type_annotation: "string",
407                    optional: false,
408                },
409                PropDef {
410                    name: "variant",
411                    type_annotation: "\"primary\" | \"secondary\"",
412                    optional: true,
413                },
414            ],
415            description: "Clickable button",
416        };
417        let line = build_component_line(&comp);
418        assert_eq!(
419            line,
420            "Button(label: string, variant?: \"primary\" | \"secondary\") — Clickable button"
421        );
422    }
423
424    #[test]
425    fn test_build_component_line_no_props() {
426        let comp = ComponentDef {
427            name: "Separator",
428            props: &[],
429            description: "Visual divider",
430        };
431        let line = build_component_line(&comp);
432        assert_eq!(line, "Separator() — Visual divider");
433    }
434}