diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 34cfb93a51..54338399b6 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1577,6 +1577,8 @@ dependencies = [ "baml_base", "baml_builtins", "baml_compiler2_emit", + "baml_compiler2_hir", + "baml_compiler2_tir", "baml_compiler2_visualization", "baml_compiler_diagnostics", "baml_db", diff --git a/baml_language/crates/baml_compiler2_ast/src/ast.rs b/baml_language/crates/baml_compiler2_ast/src/ast.rs index 2373a7e758..9123cc36ae 100644 --- a/baml_language/crates/baml_compiler2_ast/src/ast.rs +++ b/baml_language/crates/baml_compiler2_ast/src/ast.rs @@ -710,6 +710,8 @@ pub struct ConfigItemDef { pub struct TestDef { pub name: Name, pub config_items: Vec, + /// Pre-serialized JSON object built from the `args { … }` block in the CST. + pub args_json: String, pub span: TextRange, pub name_span: TextRange, } diff --git a/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs b/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs index f310009eba..d646b5f751 100644 --- a/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs +++ b/baml_language/crates/baml_compiler2_ast/src/lower_cst.rs @@ -727,9 +727,22 @@ fn lower_test(node: &SyntaxNode, diags: &mut Vec) -> Option< .map(|cb| lower_config_block(&cb, "test", &test_name, diags)) .unwrap_or_default(); + // Build JSON from the CST `args { … }` block before structural info is lost. + let args_json = test + .config_block() + .and_then(|cb| { + cb.items().find(|item| { + item.key().map(|k| k.text() == "args").unwrap_or(false) + }) + }) + .and_then(|item| item.nested_block()) + .map(|block| cst_config_block_to_json(&block)) + .unwrap_or_else(|| "{}".to_string()); + Some(TestDef { name: Name::new(&test_name), config_items, + args_json, span: node.text_range(), name_span: name_token.text_range(), }) @@ -1543,6 +1556,132 @@ fn validate_client_options( } } +/// Convert a CST `ConfigBlock` (the `args { … }` block of a test) into a JSON object string. +fn cst_config_block_to_json(cb: &ast::ConfigBlock) -> String { + let entries: Vec = cb + .items() + .filter_map(|item| { + let key = item.key()?.text().to_string(); + let value = cst_config_item_value_to_json(&item); + Some(format!("\"{}\":{}", escape_json_string(&key), value)) + }) + .collect(); + format!("{{{}}}", entries.join(",")) +} + +/// Convert a single CST config item's value to a JSON value string. +fn cst_config_item_value_to_json(item: &ast::ConfigItem) -> String { + use baml_compiler_syntax::SyntaxKind; + + // Nested block → JSON object + if let Some(nested) = item.nested_block() { + return cst_config_block_to_json(&nested); + } + + // Array literal → JSON array + if item.is_array() { + if let Some(arr_node) = item.array_node() { + let elements: Vec = arr_node + .children() + .filter(|child| child.kind() == SyntaxKind::CONFIG_VALUE) + .map(|cv: baml_compiler_syntax::SyntaxNode| cst_config_value_node_to_json(&cv)) + .collect(); + return format!("[{}]", elements.join(",")); + } + return "[]".to_string(); + } + + // Scalar value + if let Some(cv_node) = item.config_value_node() { + return cst_config_value_node_to_json(&cv_node); + } + + "null".to_string() +} + +/// Convert a CONFIG_VALUE syntax node to a JSON value string. +fn cst_config_value_node_to_json(cv_node: &baml_compiler_syntax::SyntaxNode) -> String { + use baml_compiler_syntax::SyntaxKind; + + // Collect all meaningful tokens + let tokens: Vec<_> = cv_node + .descendants_with_tokens() + .filter_map(rowan::NodeOrToken::into_token) + .filter(|t| !t.kind().is_trivia()) + .collect(); + + if tokens.is_empty() { + return "null".to_string(); + } + + // Check for string literal (has QUOTE tokens) + let has_quotes = tokens.iter().any(|t| t.kind() == SyntaxKind::QUOTE); + if has_quotes { + // Extract the string content (everything between quotes) + let text: String = tokens + .iter() + .filter(|t| t.kind() != SyntaxKind::QUOTE) + .map(|t| t.text().to_string()) + .collect(); + return format!("\"{}\"", escape_json_string(&text)); + } + + // Check for array literal + if cv_node + .children() + .any(|child| child.kind() == SyntaxKind::ARRAY_LITERAL) + { + if let Some(arr_node) = cv_node + .children() + .find(|child| child.kind() == SyntaxKind::ARRAY_LITERAL) + { + let elements: Vec = arr_node + .children() + .filter(|child| child.kind() == SyntaxKind::CONFIG_VALUE) + .map(|cv| cst_config_value_node_to_json(&cv)) + .collect(); + return format!("[{}]", elements.join(",")); + } + } + + // Check for integer literal + let all_int = tokens + .iter() + .all(|t| matches!(t.kind(), SyntaxKind::INTEGER_LITERAL | SyntaxKind::MINUS)); + if all_int { + let text: String = tokens.iter().map(|t| t.text().to_string()).collect(); + return text; + } + + // Check for float literal + let all_float = tokens.iter().all(|t| { + matches!( + t.kind(), + SyntaxKind::FLOAT_LITERAL | SyntaxKind::MINUS + ) + }); + if all_float { + let text: String = tokens.iter().map(|t| t.text().to_string()).collect(); + return text; + } + + // Bare word — check for booleans and null + let text: String = tokens.iter().map(|t| t.text().to_string()).collect(); + match text.as_str() { + "true" | "false" | "null" => text, + // Bare word treated as string + _ => format!("\"{}\"", escape_json_string(&text)), + } +} + +fn escape_json_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + fn lower_config_block( cb: &ast::ConfigBlock, block_kind: &'static str, diff --git a/baml_language/crates/baml_compiler2_hir/src/item_tree.rs b/baml_language/crates/baml_compiler2_hir/src/item_tree.rs index 44f26a65a3..0b90406eff 100644 --- a/baml_language/crates/baml_compiler2_hir/src/item_tree.rs +++ b/baml_language/crates/baml_compiler2_hir/src/item_tree.rs @@ -179,6 +179,8 @@ pub struct Test { pub function_refs: Vec, /// Test arguments as key-value pairs. pub args: Vec<(Name, TestArgValue)>, + /// Pre-serialized JSON of args, built from the CST before structural info is lost. + pub raw_args_json: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -462,11 +464,11 @@ impl ItemTree { // Values may be comma-separated or a single name item.value .split(',') - .map(|s| Name::new(s.trim().trim_matches('"'))) + .map(|s| Name::new(s.trim().trim_matches(|c| c == '"' || c == '[' || c == ']').trim())) .collect::>() }) .collect(); - // Args come from config_items with key "args" — store raw; complex parsing skipped + // Structured args parsing skipped for now; use pre-serialized JSON from CST lowering. let args = Vec::new(); self.tests.insert( id, @@ -474,6 +476,7 @@ impl ItemTree { name: t.name.clone(), function_refs, args, + raw_args_json: t.args_json.clone(), }, ); id diff --git a/baml_language/crates/baml_project/src/symbols.rs b/baml_language/crates/baml_project/src/symbols.rs index b4742a8c27..81e4c71ca4 100644 --- a/baml_language/crates/baml_project/src/symbols.rs +++ b/baml_language/crates/baml_project/src/symbols.rs @@ -141,8 +141,7 @@ pub fn list_tests_with_metadata(db: &ProjectDatabase) -> Vec { .map(std::string::ToString::to_string) .unwrap_or_default(); - // args parsing is skipped in canary's alloc_test — always empty for now - let args_json = "{}".to_string(); + let args_json = test.raw_args_json.clone(); result.push(TestSymbol { name: name.to_string(), diff --git a/baml_language/crates/bex_project/Cargo.toml b/baml_language/crates/bex_project/Cargo.toml index 89f58d3575..8de47a7247 100644 --- a/baml_language/crates/bex_project/Cargo.toml +++ b/baml_language/crates/bex_project/Cargo.toml @@ -21,6 +21,8 @@ workspace = true baml_base = { workspace = true } baml_builtins = { workspace = true } baml_compiler2_emit = { workspace = true } +baml_compiler2_hir = { workspace = true } +baml_compiler2_tir = { workspace = true } baml_compiler2_visualization = { workspace = true } baml_compiler_diagnostics = { workspace = true } baml_db = { workspace = true } diff --git a/baml_language/crates/bex_project/src/bex_lsp/mod.rs b/baml_language/crates/bex_project/src/bex_lsp/mod.rs index 6ce500f10b..c2b62d07e3 100644 --- a/baml_language/crates/bex_project/src/bex_lsp/mod.rs +++ b/baml_language/crates/bex_project/src/bex_lsp/mod.rs @@ -68,6 +68,42 @@ pub struct FunctionInfo { pub kind: FunctionKind, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, + pub params: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParamInfo { + pub name: String, + pub field_type: FieldType, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FieldType { + String, + Int, + Float, + Bool, + Null, + Enum { name: String, values: Vec }, + Class { name: String, fields: Vec }, + List { item: Box }, + Map { key: Box, value: Box }, + Optional { inner: Box }, + Union { variants: Vec }, + Literal { value: LiteralValue }, + RecursiveRef { name: String }, + Any, + Media { media_type: String }, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub enum LiteralValue { + String(String), + Int(i64), + Bool(bool), } #[derive(Debug, Clone, serde::Serialize)] diff --git a/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs b/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs index 67c3a5056b..3b1913a9d4 100644 --- a/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs +++ b/baml_language/crates/bex_project/src/bex_lsp/multi_project/mod.rs @@ -4,10 +4,17 @@ mod notification; mod request; mod wasm_helpers; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use ::std::sync::Arc; +use baml_compiler2_hir::package::PackageId; +use baml_compiler2_tir::{ + package_interface::{ExportedType, package_interface}, + ty::{PrimitiveType, Ty}, +}; +use baml_db::Name; + /// Factory that creates [`sys_ops::SysOps`] for a given project root. type SysOpFactory = std::sync::Arc std::sync::Arc + Send + Sync>; @@ -513,10 +520,16 @@ impl BexMulitProject { let db_guard = project.project.db.lock().unwrap(); let db = db_guard.db(); + + // Get the full package interface for type resolution + let pkg_id = PackageId::new(db, Name::new("user")); + let iface = package_interface(db, pkg_id); + let functions = baml_project::list_functions_with_metadata(db) .into_iter() + .filter(|f| !f.is_sub_function) .map(|f| crate::bex_lsp::FunctionInfo { - name: f.name, + name: f.name.clone(), kind: if f.is_llm { crate::bex_lsp::FunctionKind::Llm } else { @@ -531,6 +544,7 @@ impl BexMulitProject { } else { None }, + params: resolve_function_params(&f.name, iface), }) .collect(); @@ -695,3 +709,146 @@ pub fn new_lsp( ) -> impl crate::bex_lsp::BexLsp { BexMulitProject::new(sys_op_factory, sender, playground_sender, fs, event_sink) } + +// --------------------------------------------------------------------------- +// Parameter schema resolution helpers +// --------------------------------------------------------------------------- + +/// Resolve a function's parameter names and types into presentation-layer ParamInfo. +fn resolve_function_params( + function_name: &str, + iface: &baml_compiler2_tir::package_interface::PackageInterface, +) -> Vec { + for ns_funcs in iface.functions.values() { + if let Some(exported_fn) = ns_funcs.get(&Name::new(function_name)) { + return exported_fn + .params + .iter() + .map(|(name, ty)| crate::bex_lsp::ParamInfo { + name: name.to_string(), + field_type: ty_to_field_type(ty, iface, &mut HashSet::new()), + }) + .collect(); + } + } + Vec::new() +} + +/// Convert a resolved Ty into a FieldType for the playground form. +/// `ancestors` tracks class names on the current expansion path for cycle detection. +fn ty_to_field_type( + ty: &Ty, + iface: &baml_compiler2_tir::package_interface::PackageInterface, + ancestors: &mut HashSet, +) -> crate::bex_lsp::FieldType { + match ty { + Ty::Primitive(PrimitiveType::String, _) => crate::bex_lsp::FieldType::String, + Ty::Primitive(PrimitiveType::Int, _) => crate::bex_lsp::FieldType::Int, + Ty::Primitive(PrimitiveType::Float, _) => crate::bex_lsp::FieldType::Float, + Ty::Primitive(PrimitiveType::Bool, _) => crate::bex_lsp::FieldType::Bool, + Ty::Primitive(PrimitiveType::Null, _) => crate::bex_lsp::FieldType::Null, + Ty::Primitive(PrimitiveType::Image, _) => crate::bex_lsp::FieldType::Media { + media_type: "image".to_string(), + }, + Ty::Primitive(PrimitiveType::Audio, _) => crate::bex_lsp::FieldType::Media { + media_type: "audio".to_string(), + }, + Ty::Primitive(PrimitiveType::Video, _) => crate::bex_lsp::FieldType::Media { + media_type: "video".to_string(), + }, + Ty::Primitive(PrimitiveType::Pdf, _) => crate::bex_lsp::FieldType::Media { + media_type: "pdf".to_string(), + }, + + Ty::Enum(qtn, _) => { + let enum_name = qtn.name().to_string(); + let values = find_enum_values(iface, &enum_name); + crate::bex_lsp::FieldType::Enum { + name: enum_name, + values, + } + } + + Ty::Class(qtn, _) => { + let class_name = qtn.name().to_string(); + if !ancestors.insert(class_name.clone()) { + // Cycle detected + return crate::bex_lsp::FieldType::RecursiveRef { name: class_name }; + } + let fields = find_class_fields(iface, &class_name) + .into_iter() + .map(|(name, field_ty)| crate::bex_lsp::ParamInfo { + name: name.to_string(), + field_type: ty_to_field_type(&field_ty, iface, ancestors), + }) + .collect(); + ancestors.remove(&class_name); + crate::bex_lsp::FieldType::Class { + name: class_name, + fields, + } + } + + Ty::List(inner, _) | Ty::EvolvingList(inner, _) => crate::bex_lsp::FieldType::List { + item: Box::new(ty_to_field_type(inner, iface, ancestors)), + }, + + Ty::Map(key, value, _) | Ty::EvolvingMap(key, value, _) => crate::bex_lsp::FieldType::Map { + key: Box::new(ty_to_field_type(key, iface, ancestors)), + value: Box::new(ty_to_field_type(value, iface, ancestors)), + }, + + Ty::Optional(inner, _) => crate::bex_lsp::FieldType::Optional { + inner: Box::new(ty_to_field_type(inner, iface, ancestors)), + }, + + Ty::Union(variants, _) => crate::bex_lsp::FieldType::Union { + variants: variants + .iter() + .map(|v| ty_to_field_type(v, iface, ancestors)) + .collect(), + }, + + Ty::Literal(lit, _, _) => { + let value = match lit { + baml_base::Literal::String(s) => crate::bex_lsp::LiteralValue::String(s.clone()), + baml_base::Literal::Int(i) => crate::bex_lsp::LiteralValue::Int(*i), + baml_base::Literal::Bool(b) => crate::bex_lsp::LiteralValue::Bool(*b), + baml_base::Literal::Float(_) => { + // Float literals are rare; treat as Any for now + return crate::bex_lsp::FieldType::Any; + } + }; + crate::bex_lsp::FieldType::Literal { value } + } + + // Fallback — all other Ty variants map to Any + _ => crate::bex_lsp::FieldType::Any, + } +} + +/// Look up enum variant names from the PackageInterface. +fn find_enum_values( + iface: &baml_compiler2_tir::package_interface::PackageInterface, + enum_name: &str, +) -> Vec { + for ns_types in iface.types.values() { + if let Some(ExportedType::Enum { variants, .. }) = ns_types.get(&Name::new(enum_name)) { + return variants.iter().map(|v| v.to_string()).collect(); + } + } + Vec::new() +} + +/// Look up class fields from the PackageInterface. +fn find_class_fields( + iface: &baml_compiler2_tir::package_interface::PackageInterface, + class_name: &str, +) -> Vec<(Name, Ty)> { + for ns_types in iface.types.values() { + if let Some(ExportedType::Class { fields, .. }) = ns_types.get(&Name::new(class_name)) { + return fields.clone(); + } + } + Vec::new() +} diff --git a/baml_language/crates/bex_project/src/lib.rs b/baml_language/crates/bex_project/src/lib.rs index 10502e0488..27a7c45b89 100644 --- a/baml_language/crates/bex_project/src/lib.rs +++ b/baml_language/crates/bex_project/src/lib.rs @@ -74,7 +74,8 @@ pub fn new( } pub use bex_lsp::{ - BexLsp, FunctionInfo, FunctionKind, LlmCapabilities, LspClientSenderTrait, LspError, - PlaygroundNotification, PlaygroundSender, ProjectDiagnostic, ProjectUpdate, TestInfo, new_lsp, + BexLsp, FieldType, FunctionInfo, FunctionKind, LiteralValue, LlmCapabilities, + LspClientSenderTrait, LspError, ParamInfo, PlaygroundNotification, PlaygroundSender, + ProjectDiagnostic, ProjectUpdate, TestInfo, new_lsp, }; pub use fs::{BamlVFS, BulkReadFileSystem, DefaultBulkReadFileSystem, FsPath}; diff --git a/baml_language/crates/bridge_wasm/src/wasm_playground.rs b/baml_language/crates/bridge_wasm/src/wasm_playground.rs index 3dde50c70b..3f96153aeb 100644 --- a/baml_language/crates/bridge_wasm/src/wasm_playground.rs +++ b/baml_language/crates/bridge_wasm/src/wasm_playground.rs @@ -13,6 +13,43 @@ pub struct FunctionInfo { pub kind: FunctionKind, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option, + pub params: Vec, +} + +#[derive(Tsify, Serialize)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct ParamInfo { + pub name: String, + pub field_type: FieldType, +} + +#[derive(Tsify, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FieldType { + String, + Int, + Float, + Bool, + Null, + Enum { name: String, values: Vec }, + Class { name: String, fields: Vec }, + List { item: Box }, + Map { key: Box, value: Box }, + Optional { inner: Box }, + Union { variants: Vec }, + Literal { value: LiteralValue }, + RecursiveRef { name: String }, + Any, + Media { media_type: String }, +} + +#[derive(Tsify, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub enum LiteralValue { + String(String), + Int(i64), + Bool(bool), } #[derive(Tsify, Serialize)] @@ -110,6 +147,7 @@ impl From for PlaygroundNotification { build_request: c.build_request, client_name: c.client_name, }), + params: f.params.into_iter().map(convert_param_info).collect(), }) .collect(), tests: update @@ -172,3 +210,48 @@ impl bex_project::PlaygroundSender for WasmPlaygroundSender { let _ = callback.call1(&JsValue::NULL, &wasm_notif.into()); } } + +fn convert_param_info(p: bex_project::ParamInfo) -> ParamInfo { + ParamInfo { + name: p.name, + field_type: convert_field_type(p.field_type), + } +} + +fn convert_field_type(ft: bex_project::FieldType) -> FieldType { + match ft { + bex_project::FieldType::String => FieldType::String, + bex_project::FieldType::Int => FieldType::Int, + bex_project::FieldType::Float => FieldType::Float, + bex_project::FieldType::Bool => FieldType::Bool, + bex_project::FieldType::Null => FieldType::Null, + bex_project::FieldType::Enum { name, values } => FieldType::Enum { name, values }, + bex_project::FieldType::Class { name, fields } => FieldType::Class { + name, + fields: fields.into_iter().map(convert_param_info).collect(), + }, + bex_project::FieldType::List { item } => FieldType::List { + item: Box::new(convert_field_type(*item)), + }, + bex_project::FieldType::Map { key, value } => FieldType::Map { + key: Box::new(convert_field_type(*key)), + value: Box::new(convert_field_type(*value)), + }, + bex_project::FieldType::Optional { inner } => FieldType::Optional { + inner: Box::new(convert_field_type(*inner)), + }, + bex_project::FieldType::Union { variants } => FieldType::Union { + variants: variants.into_iter().map(convert_field_type).collect(), + }, + bex_project::FieldType::Literal { value } => FieldType::Literal { + value: match value { + bex_project::LiteralValue::String(s) => LiteralValue::String(s), + bex_project::LiteralValue::Int(i) => LiteralValue::Int(i), + bex_project::LiteralValue::Bool(b) => LiteralValue::Bool(b), + }, + }, + bex_project::FieldType::RecursiveRef { name } => FieldType::RecursiveRef { name }, + bex_project::FieldType::Any => FieldType::Any, + bex_project::FieldType::Media { media_type } => FieldType::Media { media_type }, + } +} diff --git a/typescript2/pkg-playground/src/ExecutionPanel.tsx b/typescript2/pkg-playground/src/ExecutionPanel.tsx index 1786fadd4c..b01713457d 100644 --- a/typescript2/pkg-playground/src/ExecutionPanel.tsx +++ b/typescript2/pkg-playground/src/ExecutionPanel.tsx @@ -25,6 +25,8 @@ import { CopyButton } from './components/CopyButton'; import { ErrorDisplay } from './components/ErrorDisplay'; import { MetadataBadges } from './components/MetadataBadges'; import { PromptStats } from './components/PromptStats'; +import { ParameterForm } from './components/ParameterForm'; +import { getDefaultValue } from './components/FieldRenderer'; import type { RuntimePort } from './runtime-port'; import type { ControlFlowGraph, @@ -32,6 +34,7 @@ import type { DiagnosticEntry, FetchLogEntry, FunctionInfo, + ParamInfo, ProjectUpdate, RunEntry, TestInfo, @@ -98,6 +101,8 @@ export const ExecutionPanel: FC = ({ port, connectionVersio const [selectedFn, setSelectedFn] = useState(null); const [argsJson, setArgsJson] = useState('{}'); + const [formData, setFormData] = useState>({}); + const [inputMode, setInputMode] = useState<'form' | 'json'>('form'); // Run history — each entry is a complete invocation with its logs + result const [runs, setRuns] = useState([]); @@ -487,6 +492,17 @@ export const ExecutionPanel: FC = ({ port, connectionVersio const onArgsJsonChange = useCallback((e: ChangeEvent) => { setArgsJson(e.target.value); + try { + setFormData(JSON.parse(e.target.value)); + } catch { + // Don't update formData until valid JSON + } + }, []); + + // Sync formData → argsJson + const handleFormDataChange = useCallback((data: Record) => { + setFormData(data); + setArgsJson(JSON.stringify(data)); }, []); // ── Run function ─────────────────────────────────────────────────────── @@ -579,6 +595,11 @@ export const ExecutionPanel: FC = ({ port, connectionVersio const handleSelectTest = useCallback((test: TestInfo) => { setSelectedFn(test.functionName); setArgsJson(test.argsJson); + try { + setFormData(JSON.parse(test.argsJson)); + } catch { + setFormData({}); + } setActiveTab('run'); }, []); @@ -656,6 +677,7 @@ export const ExecutionPanel: FC = ({ port, connectionVersio const diags = currentUpdate?.diagnostics ?? []; const selectedFnInfo = functions.find((f) => f.name === selectedFn); + const fnParams: ParamInfo[] = selectedFnInfo?.params ?? []; const canPreviewPrompt = selectedFnInfo?.capabilities?.renderPrompt ?? false; const canPreviewCurl = selectedFnInfo?.capabilities?.buildRequest ?? false; @@ -669,6 +691,22 @@ export const ExecutionPanel: FC = ({ port, connectionVersio if (activeTab === 'curl' && !canPreviewCurl) setActiveTab('run'); }, [activeTab, canPreviewPrompt, canPreviewCurl]); + // Reset form data when function changes — build defaults from schema + useEffect(() => { + if (!fnParams.length) { + setFormData({}); + setArgsJson('{}'); + return; + } + const defaults: Record = {}; + for (const param of fnParams) { + defaults[param.name] = getDefaultValue(param.fieldType); + } + setFormData(defaults); + setArgsJson(JSON.stringify(defaults)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFn]); // Only react to function changes, not param reference changes + const errors = diags.filter((d) => d.severity === 'error'); const warnings = diags.filter((d) => d.severity === 'warning'); const hasErrors = errors.length > 0; @@ -1031,17 +1069,41 @@ export const ExecutionPanel: FC = ({ port, connectionVersio {/* Execution area */} {/* Args */} -
- - args - - +
+
+ args + { + if (mode === 'form' && inputMode === 'json') { + try { setFormData(JSON.parse(argsJson)); } catch { /* keep current formData */ } + } + setInputMode(mode); + }} + options={[ + { value: 'form' as const, label: 'Form' }, + { value: 'json' as const, label: 'JSON' }, + ]} + size="sm" + /> +
+ {inputMode === 'form' ? ( + + ) : ( +
+ +
+ )}
{/* Run history (scrollable) */} diff --git a/typescript2/pkg-playground/src/components/FieldRenderer.tsx b/typescript2/pkg-playground/src/components/FieldRenderer.tsx new file mode 100644 index 0000000000..948a4fcec5 --- /dev/null +++ b/typescript2/pkg-playground/src/components/FieldRenderer.tsx @@ -0,0 +1,435 @@ +import { type FC, useState } from 'react'; +import type { FieldType } from '../worker-protocol'; +import { Input } from './ui/input'; +import { Textarea } from './ui/textarea'; +import { ToggleGroup } from './ui/toggle-group'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; +import { Button } from './ui/button'; +import { ChevronRight, Plus, Trash2 } from 'lucide-react'; +import { cn } from '../lib/utils'; + +interface FieldRendererProps { + fieldType: FieldType; + value: unknown; + onChange: (value: unknown) => void; + depth?: number; + path?: string; +} + +export const FieldRenderer: FC = ({ + fieldType, + value, + onChange, + depth = 0, + path = '$', +}) => { + switch (fieldType.type) { + case 'string': + return ( + onChange(e.target.value)} + className="h-7 font-vsc-mono text-xs" + placeholder="string" + /> + ); + + case 'int': + return ( + { + const v = e.target.value; + onChange(v === '' ? undefined : parseInt(v, 10)); + }} + className="h-7 font-vsc-mono text-xs" + placeholder="0" + /> + ); + + case 'float': + return ( + { + const v = e.target.value; + onChange(v === '' ? undefined : parseFloat(v)); + }} + className="h-7 font-vsc-mono text-xs" + placeholder="0.0" + /> + ); + + case 'bool': + return ( + + ); + + case 'null': + return ( + null + ); + + case 'enum': + if (fieldType.values.length <= 5) { + return ( + onChange(v)} + options={fieldType.values.map((v) => ({ value: v, label: v }))} + size="sm" + /> + ); + } + return ( + + ); + + case 'optional': + return ( + + ); + + case 'literal': + return ( + + {JSON.stringify(fieldType.value.value)} + + ); + + case 'class': + // At render depth >= 3, fall back to JSON textarea to prevent UI clutter + if (depth >= 3) { + return ; + } + return ( + + ); + + case 'list': + return ( + + ); + + case 'map': + return ( + + ); + + case 'union': + return ; + + case 'recursiveRef': + return ( +
+ + {fieldType.name} (JSON) + + +
+ ); + + case 'any': + case 'media': + default: + return ; + } +}; + +/** Optional field — toggle switch to enable/disable + renders inner type when enabled. */ +const OptionalField: FC< + FieldRendererProps & { fieldType: Extract } +> = ({ fieldType, value, onChange, depth, path }) => { + const isEnabled = value !== undefined && value !== null; + return ( +
+ + {isEnabled && ( + + )} +
+ ); +}; + +/** Raw JSON textarea fallback for unsupported types. */ +const JsonFallback: FC<{ value: unknown; onChange: (value: unknown) => void }> = ({ + value, + onChange, +}) => { + const [text, setText] = useState(() => + value !== undefined ? JSON.stringify(value, null, 2) : '', + ); + return ( +