diff --git a/.idea/runConfigurations/RunIDEA.xml b/.idea/runConfigurations/RunIDEA.xml
index a8cacb15cb..9b361aa743 100644
--- a/.idea/runConfigurations/RunIDEA.xml
+++ b/.idea/runConfigurations/RunIDEA.xml
@@ -10,7 +10,7 @@
diff --git a/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.android.kt b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.android.kt
new file mode 100644
index 0000000000..379315176f
--- /dev/null
+++ b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.android.kt
@@ -0,0 +1,178 @@
+package cc.unitmesh.agent.parser
+
+import cc.unitmesh.agent.logging.getLogger
+
+private val logger = getLogger("NanoDSLValidator.Android")
+
+/**
+ * Android implementation of NanoDSLValidator.
+ *
+ * Attempts to use xuiper-ui's NanoParser if available via reflection,
+ * otherwise falls back to basic validation.
+ *
+ * Note: Android shares the JVM runtime but xuiper-ui may not be bundled
+ * in Android apps due to size concerns.
+ */
+actual class NanoDSLValidator actual constructor() {
+
+ private val fullParserAvailable: Boolean
+ private var nanoDSLClass: Class<*>? = null
+ private var validateMethod: java.lang.reflect.Method? = null
+ private var toJsonMethod: java.lang.reflect.Method? = null
+
+ init {
+ // Try to load xuiper-ui's NanoDSL class via reflection
+ fullParserAvailable = try {
+ nanoDSLClass = Class.forName("cc.unitmesh.xuiper.dsl.NanoDSL")
+ validateMethod = nanoDSLClass!!.getDeclaredMethod("validate", String::class.java)
+ toJsonMethod = nanoDSLClass!!.getDeclaredMethod("toJson", String::class.java, Boolean::class.javaPrimitiveType)
+ logger.info { "NanoDSL parser available via xuiper-ui" }
+ true
+ } catch (e: Exception) {
+ logger.debug { "NanoDSL parser not available: ${e.message}" }
+ false
+ }
+ }
+
+ actual fun validate(source: String): NanoDSLValidationResult {
+ if (source.isBlank()) {
+ return NanoDSLValidationResult(
+ isValid = false,
+ errors = listOf(ValidationError("Empty source code", 0))
+ )
+ }
+
+ // Try to use full parser if available
+ if (fullParserAvailable && validateMethod != null) {
+ return try {
+ val result = validateMethod!!.invoke(nanoDSLClass!!.kotlin.objectInstance, source)
+ convertValidationResult(result)
+ } catch (e: Exception) {
+ logger.warn(e) { "Full validation failed, falling back to basic validation" }
+ performBasicValidation(source)
+ }
+ }
+
+ return performBasicValidation(source)
+ }
+
+ actual fun parse(source: String): NanoDSLParseResult {
+ val validationResult = validate(source)
+ if (!validationResult.isValid) {
+ return NanoDSLParseResult.Failure(validationResult.errors)
+ }
+
+ // Try to use full parser if available
+ if (fullParserAvailable && toJsonMethod != null) {
+ return try {
+ val irJson = toJsonMethod!!.invoke(nanoDSLClass!!.kotlin.objectInstance, source, true) as String
+ NanoDSLParseResult.Success(irJson)
+ } catch (e: Exception) {
+ logger.warn(e) { "Full parsing failed: ${e.message}" }
+ val errorMessage = e.cause?.message ?: e.message ?: "Unknown parse error"
+ NanoDSLParseResult.Failure(listOf(ValidationError(errorMessage, 0)))
+ }
+ }
+
+ // Fallback: return a minimal IR wrapper
+ return NanoDSLParseResult.Success(createMinimalIR(source))
+ }
+
+ private fun convertValidationResult(result: Any): NanoDSLValidationResult {
+ return try {
+ val isValid = result::class.java.getMethod("isValid").invoke(result) as Boolean
+ val errorsField = result::class.java.getMethod("getErrors").invoke(result) as List<*>
+ val warningsField = result::class.java.getMethod("getWarnings").invoke(result) as List<*>
+
+ val errors = errorsField.mapNotNull { error ->
+ if (error == null) return@mapNotNull null
+ val message = error::class.java.getMethod("getMessage").invoke(error) as String
+ val line = error::class.java.getMethod("getLine").invoke(error) as Int
+ ValidationError(message, line)
+ }
+
+ val warnings = warningsField.mapNotNull { warning ->
+ if (warning == null) return@mapNotNull null
+ warning::class.java.getMethod("getMessage").invoke(warning) as String
+ }
+
+ NanoDSLValidationResult(isValid, errors, warnings)
+ } catch (e: Exception) {
+ logger.warn(e) { "Failed to convert validation result" }
+ NanoDSLValidationResult(isValid = true)
+ }
+ }
+
+ private fun performBasicValidation(source: String): NanoDSLValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+ val lines = source.lines()
+
+ // Check for component definition
+ val hasComponentDef = lines.any { it.trim().startsWith("component ") && it.trim().endsWith(":") }
+ if (!hasComponentDef) {
+ errors.add(ValidationError(
+ "Missing component definition. Expected 'component Name:'",
+ 0,
+ "Add 'component YourComponentName:' at the start"
+ ))
+ }
+
+ // Check indentation
+ for ((lineNum, line) in lines.withIndex()) {
+ if (line.isBlank()) continue
+
+ if (line.contains('\t')) {
+ errors.add(ValidationError(
+ "Tabs are not allowed. Use 4 spaces for indentation.",
+ lineNum,
+ "Replace tabs with 4 spaces"
+ ))
+ }
+ }
+
+ // Check for unclosed blocks
+ var parenCount = 0
+ var braceCount = 0
+ for (line in lines) {
+ for (char in line) {
+ when (char) {
+ '(' -> parenCount++
+ ')' -> parenCount--
+ '{' -> braceCount++
+ '}' -> braceCount--
+ }
+ }
+ }
+
+ if (parenCount != 0) {
+ errors.add(ValidationError("Unbalanced parentheses", 0))
+ }
+
+ if (braceCount != 0) {
+ errors.add(ValidationError("Unbalanced braces", 0))
+ }
+
+ return NanoDSLValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors,
+ warnings = warnings
+ )
+ }
+
+ private fun createMinimalIR(source: String): String {
+ val componentNameMatch = Regex("""component\s+(\w+):""").find(source)
+ val componentName = componentNameMatch?.groupValues?.get(1) ?: "UnknownComponent"
+
+ // Escape the source for JSON
+ val escapedSource = source
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+ return """{"type":"Component","props":{"name":"$componentName"},"source":"$escapedSource"}"""
+ }
+}
+
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.kt
new file mode 100644
index 0000000000..bf68f6c829
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.kt
@@ -0,0 +1,76 @@
+package cc.unitmesh.agent.parser
+
+/**
+ * Platform-agnostic NanoDSL validation result
+ */
+data class NanoDSLValidationResult(
+ val isValid: Boolean,
+ val errors: List = emptyList(),
+ val warnings: List = emptyList()
+)
+
+/**
+ * Validation error with location information
+ */
+data class ValidationError(
+ val message: String,
+ val line: Int,
+ val suggestion: String? = null
+)
+
+/**
+ * Parse result for NanoDSL source code
+ */
+sealed class NanoDSLParseResult {
+ /**
+ * Successful parse with IR JSON representation
+ */
+ data class Success(val irJson: String) : NanoDSLParseResult()
+
+ /**
+ * Failed parse with error details
+ */
+ data class Failure(val errors: List) : NanoDSLParseResult()
+
+ fun isSuccess(): Boolean = this is Success
+ fun getIrJsonOrNull(): String? = (this as? Success)?.irJson
+}
+
+/**
+ * NanoDSL validator interface - cross-platform
+ *
+ * JVM platforms perform full AST parsing via xuiper-ui's NanoParser.
+ * Non-JVM platforms perform lightweight syntax checks.
+ *
+ * Usage:
+ * ```kotlin
+ * val validator = NanoDSLValidator()
+ * val result = validator.validate(source)
+ * if (result.isValid) {
+ * val parseResult = validator.parse(source)
+ * // Use IR JSON for rendering
+ * }
+ * ```
+ */
+expect class NanoDSLValidator() {
+ /**
+ * Validate NanoDSL source code without full parsing.
+ *
+ * @param source The NanoDSL source code to validate
+ * @return Validation result with errors and warnings
+ */
+ fun validate(source: String): NanoDSLValidationResult
+
+ /**
+ * Parse NanoDSL source code and convert to IR JSON.
+ *
+ * On JVM platforms, this performs full AST parsing and converts to NanoIR.
+ * On non-JVM platforms, this performs basic validation only and returns
+ * a simple wrapper or failure.
+ *
+ * @param source The NanoDSL source code to parse
+ * @return Parse result with IR JSON or errors
+ */
+ fun parse(source: String): NanoDSLParseResult
+}
+
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt
index 1281248b9a..e77dc20077 100644
--- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt
@@ -128,4 +128,23 @@ interface CodingAgentRenderer {
// Default: return error for renderers that don't support async sessions
return ToolResult.Error("Async session not supported by this renderer")
}
+
+ /**
+ * Render generated NanoDSL UI code.
+ * Called when NanoDSLAgent generates UI code.
+ *
+ * JVM platforms (Compose, Jewel) can render a live UI preview using NanoIR.
+ * Non-JVM platforms (CLI, VSCode) show the source code with syntax highlighting.
+ *
+ * @param source The generated NanoDSL source code
+ * @param irJson Optional IR JSON for rendering (available on JVM platforms)
+ * @param metadata Additional metadata (componentName, attempts, isValid, etc.)
+ */
+ fun renderNanoDSL(
+ source: String,
+ irJson: String? = null,
+ metadata: Map = emptyMap()
+ ) {
+ // Default: no-op for renderers that don't support NanoDSL rendering
+ }
}
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt
index f6180c9fa4..4d3b3f9a9e 100644
--- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt
@@ -190,6 +190,36 @@ sealed class TimelineItem(
fun isSuccess(): Boolean = exitCode == 0
}
+ /**
+ * NanoDSL generated UI item - displays generated NanoDSL code with optional live preview.
+ *
+ * On JVM platforms with xuiper-ui available, the irJson can be used to render
+ * a live UI preview using NanoRenderer.
+ *
+ * On non-JVM platforms or when irJson is null, shows the source code only.
+ */
+ data class NanoDSLItem(
+ /** The generated NanoDSL source code */
+ val source: String,
+ /** Optional IR JSON for rendering (JVM platforms only) */
+ val irJson: String? = null,
+ /** Component name extracted from source */
+ val componentName: String? = null,
+ /** Number of generation attempts (1 = first try succeeded) */
+ val generationAttempts: Int = 1,
+ /** Whether the generated code passed validation */
+ val isValid: Boolean = true,
+ /** Validation warnings (if any) */
+ val warnings: List = emptyList(),
+ override val timestamp: Long = Platform.getCurrentTimestamp(),
+ override val id: String = generateId()
+ ) : TimelineItem(timestamp, id) {
+ /**
+ * Check if live preview is available (requires IR JSON)
+ */
+ fun hasLivePreview(): Boolean = irJson != null
+ }
+
companion object {
/**
* Thread-safe counter for generating unique IDs.
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/NanoDSLAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/NanoDSLAgent.kt
index acd0691afb..9120dc1bab 100644
--- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/NanoDSLAgent.kt
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/NanoDSLAgent.kt
@@ -5,6 +5,10 @@ import cc.unitmesh.agent.logging.getLogger
import cc.unitmesh.agent.model.AgentDefinition
import cc.unitmesh.agent.model.PromptConfig
import cc.unitmesh.agent.model.RunConfig
+import cc.unitmesh.agent.parser.NanoDSLParseResult
+import cc.unitmesh.agent.parser.NanoDSLValidationResult
+import cc.unitmesh.agent.parser.NanoDSLValidator
+import cc.unitmesh.agent.parser.ValidationError
import cc.unitmesh.agent.tool.ToolResult
import cc.unitmesh.agent.tool.schema.DeclarativeToolSchema
import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.boolean
@@ -33,11 +37,13 @@ import kotlinx.serialization.Serializable
*/
class NanoDSLAgent(
private val llmService: KoogLLMService,
- private val promptTemplate: String = DEFAULT_PROMPT
+ private val promptTemplate: String = DEFAULT_PROMPT,
+ private val maxRetries: Int = 3
) : SubAgent(
definition = createDefinition()
) {
private val logger = getLogger("NanoDSLAgent")
+ private val validator = NanoDSLValidator()
override val priority: Int = 50 // Higher priority for UI generation tasks
@@ -63,50 +69,171 @@ class NanoDSLAgent(
onProgress("π¨ NanoDSL Agent: Generating UI from description")
onProgress("Description: ${input.description.take(80)}...")
- return try {
- val prompt = buildPrompt(input)
- onProgress("Calling LLM for code generation...")
-
- val responseBuilder = StringBuilder()
- llmService.streamPrompt(
- userPrompt = prompt,
- compileDevIns = false
- ).toList().forEach { chunk ->
- responseBuilder.append(chunk)
+ var lastGeneratedCode = ""
+ var lastErrors: List = emptyList()
+ var irJson: String? = null
+
+ for (attempt in 1..maxRetries) {
+ try {
+ val prompt = if (attempt == 1) {
+ buildPrompt(input)
+ } else {
+ // Include previous errors in retry prompt
+ buildRetryPrompt(input, lastGeneratedCode, lastErrors)
+ }
+
+ onProgress(if (attempt == 1) {
+ "Calling LLM for code generation..."
+ } else {
+ "Retry attempt $attempt/$maxRetries - fixing validation errors..."
+ })
+
+ val responseBuilder = StringBuilder()
+ llmService.streamPrompt(
+ userPrompt = prompt,
+ compileDevIns = false
+ ).toList().forEach { chunk ->
+ responseBuilder.append(chunk)
+ }
+
+ val llmResponse = responseBuilder.toString()
+
+ // Extract NanoDSL code from markdown code fence
+ val codeFence = CodeFence.parse(llmResponse)
+ val generatedCode = if (codeFence.text.isNotBlank()) {
+ codeFence.text.trim()
+ } else {
+ llmResponse.trim()
+ }
+
+ lastGeneratedCode = generatedCode
+
+ // Validate the generated code
+ val validationResult = validator.validate(generatedCode)
+
+ if (validationResult.isValid) {
+ // Try to parse and get IR JSON
+ val parseResult = validator.parse(generatedCode)
+ if (parseResult is NanoDSLParseResult.Success) {
+ irJson = parseResult.irJson
+ }
+
+ onProgress("β
Generated ${generatedCode.lines().size} lines of valid NanoDSL code" +
+ (if (attempt > 1) " (after $attempt attempts)" else ""))
+
+ // Log warnings if any
+ if (validationResult.warnings.isNotEmpty()) {
+ validationResult.warnings.forEach { warning ->
+ onProgress("β οΈ $warning")
+ }
+ }
+
+ return ToolResult.AgentResult(
+ success = true,
+ content = generatedCode,
+ metadata = buildMetadata(input, generatedCode, attempt, irJson, validationResult)
+ )
+ } else {
+ // Validation failed, prepare for retry
+ lastErrors = validationResult.errors
+ val errorMessages = validationResult.errors.joinToString("\n") {
+ "Line ${it.line}: ${it.message}" + (it.suggestion?.let { s -> " ($s)" } ?: "")
+ }
+
+ if (attempt < maxRetries) {
+ onProgress("β οΈ Validation failed, will retry: $errorMessages")
+ logger.warn { "NanoDSL validation failed (attempt $attempt): $errorMessages" }
+ } else {
+ onProgress("β Validation failed after $maxRetries attempts: $errorMessages")
+ logger.error { "NanoDSL validation failed after all retries: $errorMessages" }
+ }
+ }
+ } catch (e: Exception) {
+ logger.error(e) { "NanoDSL generation failed on attempt $attempt" }
+ // Capture exception as pseudo-error for retry prompt context
+ lastErrors = listOf(ValidationError("Generation error: ${e.message}", 0))
+ if (attempt == maxRetries) {
+ onProgress("β Generation failed: ${e.message}")
+ return ToolResult.AgentResult(
+ success = false,
+ content = "Failed to generate NanoDSL: ${e.message}",
+ metadata = mapOf(
+ "error" to (e.message ?: "Unknown error"),
+ "attempts" to attempt.toString()
+ )
+ )
+ }
+ onProgress("β οΈ Generation error, will retry: ${e.message}")
}
+ }
- val llmResponse = responseBuilder.toString()
+ // Return the last generated code even if invalid (best effort)
+ return ToolResult.AgentResult(
+ success = false,
+ content = lastGeneratedCode.ifEmpty { "Failed to generate valid NanoDSL code" },
+ metadata = mapOf(
+ "description" to input.description,
+ "attempts" to maxRetries.toString(),
+ "validationErrors" to lastErrors.joinToString("; ") { it.message },
+ "isValid" to "false"
+ )
+ )
+ }
- // Extract NanoDSL code from markdown code fence
- val codeFence = CodeFence.parse(llmResponse)
- val generatedCode = if (codeFence.text.isNotBlank()) {
- codeFence.text.trim()
- } else {
- llmResponse.trim()
+ /**
+ * Build a retry prompt that includes previous errors for self-correction
+ */
+ private fun buildRetryPrompt(
+ input: NanoDSLContext,
+ previousCode: String,
+ errors: List
+ ): String {
+ val errorFeedback = buildString {
+ appendLine("## Previous Attempt (INVALID)")
+ appendLine("```nanodsl")
+ appendLine(previousCode)
+ appendLine("```")
+ appendLine()
+ appendLine("## Validation Errors to Fix:")
+ errors.forEach { error ->
+ appendLine("- Line ${error.line}: ${error.message}")
+ error.suggestion?.let { appendLine(" Suggestion: $it") }
}
+ appendLine()
+ appendLine("## Instructions:")
+ appendLine("Please fix the above errors and generate a corrected version.")
+ appendLine("Output ONLY the corrected NanoDSL code, no explanations.")
+ }
- onProgress("β
Generated ${generatedCode.lines().size} lines of NanoDSL code")
-
- ToolResult.AgentResult(
- success = true,
- content = generatedCode,
- metadata = mapOf(
- "description" to input.description,
- "componentType" to (input.componentType ?: "auto"),
- "linesOfCode" to generatedCode.lines().size.toString(),
- "includesState" to input.includeState.toString(),
- "includesHttp" to input.includeHttp.toString()
- )
- )
- } catch (e: Exception) {
- logger.error(e) { "NanoDSL generation failed" }
- onProgress("β Generation failed: ${e.message}")
-
- ToolResult.AgentResult(
- success = false,
- content = "Failed to generate NanoDSL: ${e.message}",
- metadata = mapOf("error" to (e.message ?: "Unknown error"))
- )
+ return """
+${buildPrompt(input)}
+
+$errorFeedback
+""".trim()
+ }
+
+ /**
+ * Build metadata map for the result
+ */
+ private fun buildMetadata(
+ input: NanoDSLContext,
+ code: String,
+ attempts: Int,
+ irJson: String?,
+ validationResult: NanoDSLValidationResult
+ ): Map {
+ return buildMap {
+ put("description", input.description)
+ put("componentType", input.componentType ?: "auto")
+ put("linesOfCode", code.lines().size.toString())
+ put("includesState", input.includeState.toString())
+ put("includesHttp", input.includeHttp.toString())
+ put("attempts", attempts.toString())
+ put("isValid", validationResult.isValid.toString())
+ irJson?.let { put("irJson", it) }
+ if (validationResult.warnings.isNotEmpty()) {
+ put("warnings", validationResult.warnings.joinToString("; "))
+ }
}
}
diff --git a/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.ios.kt b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.ios.kt
new file mode 100644
index 0000000000..f675663b16
--- /dev/null
+++ b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.ios.kt
@@ -0,0 +1,103 @@
+package cc.unitmesh.agent.parser
+
+/**
+ * iOS implementation of NanoDSLValidator.
+ *
+ * Performs basic syntax validation without full AST parsing.
+ * Full parsing with IR generation is only available on JVM platforms.
+ */
+actual class NanoDSLValidator actual constructor() {
+
+ actual fun validate(source: String): NanoDSLValidationResult {
+ if (source.isBlank()) {
+ return NanoDSLValidationResult(
+ isValid = false,
+ errors = listOf(ValidationError("Empty source code", 0))
+ )
+ }
+
+ return performBasicValidation(source)
+ }
+
+ actual fun parse(source: String): NanoDSLParseResult {
+ val validationResult = validate(source)
+ if (!validationResult.isValid) {
+ return NanoDSLParseResult.Failure(validationResult.errors)
+ }
+
+ // iOS platform: return source-only IR
+ return NanoDSLParseResult.Success(createSourceOnlyIR(source))
+ }
+
+ private fun performBasicValidation(source: String): NanoDSLValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+ val lines = source.lines()
+
+ // Check for component definition
+ val hasComponentDef = lines.any { it.trim().startsWith("component ") && it.trim().endsWith(":") }
+ if (!hasComponentDef) {
+ errors.add(ValidationError(
+ "Missing component definition. Expected 'component Name:'",
+ 0,
+ "Add 'component YourComponentName:' at the start"
+ ))
+ }
+
+ // Check indentation
+ for ((lineNum, line) in lines.withIndex()) {
+ if (line.isBlank()) continue
+
+ if (line.contains('\t')) {
+ errors.add(ValidationError(
+ "Tabs are not allowed. Use 4 spaces for indentation.",
+ lineNum,
+ "Replace tabs with 4 spaces"
+ ))
+ }
+ }
+
+ // Check for unclosed blocks
+ var parenCount = 0
+ var braceCount = 0
+ for (line in lines) {
+ for (char in line) {
+ when (char) {
+ '(' -> parenCount++
+ ')' -> parenCount--
+ '{' -> braceCount++
+ '}' -> braceCount--
+ }
+ }
+ }
+
+ if (parenCount != 0) {
+ errors.add(ValidationError("Unbalanced parentheses", 0))
+ }
+
+ if (braceCount != 0) {
+ errors.add(ValidationError("Unbalanced braces", 0))
+ }
+
+ return NanoDSLValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors,
+ warnings = warnings
+ )
+ }
+
+ private fun createSourceOnlyIR(source: String): String {
+ val componentNameMatch = Regex("""component\s+(\w+):""").find(source)
+ val componentName = componentNameMatch?.groupValues?.get(1) ?: "UnknownComponent"
+
+ val escapedSource = source
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+ return """{"type":"Component","props":{"name":"$componentName"},"source":"$escapedSource"}"""
+ }
+}
+
diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt
index 7d23bc88eb..34c3cb58ed 100644
--- a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt
+++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt
@@ -84,6 +84,19 @@ data class JsPlanSummaryData(
}
}
+/**
+ * JS-friendly NanoDSL rendering data
+ */
+@JsExport
+data class JsNanoDSLData(
+ val source: String,
+ val irJson: String?,
+ val componentName: String?,
+ val generationAttempts: Int,
+ val isValid: Boolean,
+ val warnings: Array
+)
+
/**
* JS-friendly renderer interface
* Allows TypeScript to provide custom rendering implementations
@@ -112,6 +125,9 @@ interface JsCodingAgentRenderer {
// Plan summary bar (optional - default no-op in BaseRenderer)
fun renderPlanSummary(summary: JsPlanSummaryData) {}
+
+ // NanoDSL rendering (optional - default no-op in BaseRenderer)
+ fun renderNanoDSL(data: JsNanoDSLData) {}
}
/**
@@ -183,5 +199,17 @@ class JsRendererAdapter(private val jsRenderer: JsCodingAgentRenderer) : CodingA
override fun renderPlanSummary(summary: PlanSummaryData) {
jsRenderer.renderPlanSummary(JsPlanSummaryData.from(summary))
}
+
+ override fun renderNanoDSL(source: String, irJson: String?, metadata: Map) {
+ val data = JsNanoDSLData(
+ source = source,
+ irJson = irJson,
+ componentName = metadata["componentName"],
+ generationAttempts = metadata["attempts"]?.toIntOrNull() ?: 1,
+ isValid = metadata["isValid"]?.toBoolean() ?: true,
+ warnings = metadata["warnings"]?.split(";")?.toTypedArray() ?: emptyArray()
+ )
+ jsRenderer.renderNanoDSL(data)
+ }
}
diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.js.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.js.kt
new file mode 100644
index 0000000000..c125c0cb58
--- /dev/null
+++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.js.kt
@@ -0,0 +1,153 @@
+package cc.unitmesh.agent.parser
+
+/**
+ * JavaScript implementation of NanoDSLValidator.
+ *
+ * Performs basic syntax validation without full AST parsing.
+ * Full parsing with IR generation is only available on JVM platforms.
+ */
+actual class NanoDSLValidator actual constructor() {
+
+ actual fun validate(source: String): NanoDSLValidationResult {
+ if (source.isBlank()) {
+ return NanoDSLValidationResult(
+ isValid = false,
+ errors = listOf(ValidationError("Empty source code", 0))
+ )
+ }
+
+ return performBasicValidation(source)
+ }
+
+ actual fun parse(source: String): NanoDSLParseResult {
+ val validationResult = validate(source)
+ if (!validationResult.isValid) {
+ return NanoDSLParseResult.Failure(validationResult.errors)
+ }
+
+ // JavaScript platform: return source-only IR
+ return NanoDSLParseResult.Success(createSourceOnlyIR(source))
+ }
+
+ /**
+ * Basic validation without full AST parsing
+ */
+ private fun performBasicValidation(source: String): NanoDSLValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+ val lines = source.lines()
+
+ // Check for component definition
+ val hasComponentDef = lines.any { it.trim().startsWith("component ") && it.trim().endsWith(":") }
+ if (!hasComponentDef) {
+ errors.add(ValidationError(
+ "Missing component definition. Expected 'component Name:'",
+ 0,
+ "Add 'component YourComponentName:' at the start"
+ ))
+ }
+
+ // Check indentation consistency
+ for ((lineNum, line) in lines.withIndex()) {
+ if (line.isBlank()) continue
+
+ val indent = line.takeWhile { it == ' ' }.length
+
+ // Check for tabs (not allowed)
+ if (line.contains('\t')) {
+ errors.add(ValidationError(
+ "Tabs are not allowed. Use 4 spaces for indentation.",
+ lineNum,
+ "Replace tabs with 4 spaces"
+ ))
+ }
+
+ // Check for odd indentation (should be multiple of 4)
+ if (indent % 4 != 0) {
+ warnings.add("Line ${lineNum + 1}: Indentation should be a multiple of 4 spaces")
+ }
+ }
+
+ // Check for known components
+ val knownComponents = setOf(
+ "VStack", "HStack", "Card", "Text", "Button", "Input", "Image",
+ "Badge", "Checkbox", "Toggle", "Select", "List", "Grid",
+ "Spacer", "Divider", "Form", "Section"
+ )
+
+ for ((lineNum, line) in lines.withIndex()) {
+ val trimmed = line.trim()
+
+ // Check for component-like patterns
+ val componentMatch = Regex("""^(\w+)(?:\(.*\))?:\s*$""").find(trimmed)
+ if (componentMatch != null) {
+ val componentName = componentMatch.groupValues[1]
+ if (componentName !in knownComponents &&
+ componentName != "component" &&
+ componentName != "state" &&
+ componentName != "if" &&
+ componentName != "for" &&
+ componentName != "on_click" &&
+ componentName != "content") {
+ warnings.add("Line ${lineNum + 1}: Unknown component '$componentName'")
+ }
+ }
+ }
+
+ // Check for unclosed blocks
+ var parenCount = 0
+ var braceCount = 0
+ for (line in lines) {
+ for (char in line) {
+ when (char) {
+ '(' -> parenCount++
+ ')' -> parenCount--
+ '{' -> braceCount++
+ '}' -> braceCount--
+ }
+ }
+ }
+
+ if (parenCount != 0) {
+ errors.add(ValidationError(
+ "Unbalanced parentheses",
+ 0,
+ "Check for missing '(' or ')'"
+ ))
+ }
+
+ if (braceCount != 0) {
+ errors.add(ValidationError(
+ "Unbalanced braces",
+ 0,
+ "Check for missing '{' or '}'"
+ ))
+ }
+
+ return NanoDSLValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors,
+ warnings = warnings
+ )
+ }
+
+ /**
+ * Create a source-only IR JSON for non-JVM platforms
+ */
+ private fun createSourceOnlyIR(source: String): String {
+ // Extract component name from source
+ val componentNameMatch = Regex("""component\s+(\w+):""").find(source)
+ val componentName = componentNameMatch?.groupValues?.get(1) ?: "UnknownComponent"
+
+ // Escape the source for JSON
+ val escapedSource = source
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+ return """{"type":"Component","props":{"name":"$componentName"},"source":"$escapedSource"}"""
+ }
+}
+
diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.jvm.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.jvm.kt
new file mode 100644
index 0000000000..b7015a64cf
--- /dev/null
+++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.jvm.kt
@@ -0,0 +1,230 @@
+package cc.unitmesh.agent.parser
+
+import cc.unitmesh.agent.logging.getLogger
+
+private val logger = getLogger("NanoDSLValidator.JVM")
+
+/**
+ * JVM implementation of NanoDSLValidator.
+ *
+ * Attempts to use xuiper-ui's NanoParser if available via reflection,
+ * otherwise falls back to basic validation.
+ */
+actual class NanoDSLValidator actual constructor() {
+
+ private val fullParserAvailable: Boolean
+ private var nanoDSLClass: Class<*>? = null
+ private var validateMethod: java.lang.reflect.Method? = null
+ private var parseResultMethod: java.lang.reflect.Method? = null
+ private var toJsonMethod: java.lang.reflect.Method? = null
+
+ init {
+ // Try to load xuiper-ui's NanoDSL class via reflection
+ fullParserAvailable = try {
+ nanoDSLClass = Class.forName("cc.unitmesh.xuiper.dsl.NanoDSL")
+ validateMethod = nanoDSLClass!!.getDeclaredMethod("validate", String::class.java)
+ parseResultMethod = nanoDSLClass!!.getDeclaredMethod("parseResult", String::class.java)
+ toJsonMethod = nanoDSLClass!!.getDeclaredMethod("toJson", String::class.java, Boolean::class.javaPrimitiveType)
+ logger.info { "NanoDSL parser available via xuiper-ui" }
+ true
+ } catch (e: Exception) {
+ logger.debug { "NanoDSL parser not available: ${e.message}" }
+ false
+ }
+ }
+
+ actual fun validate(source: String): NanoDSLValidationResult {
+ if (source.isBlank()) {
+ return NanoDSLValidationResult(
+ isValid = false,
+ errors = listOf(ValidationError("Empty source code", 0))
+ )
+ }
+
+ // Try to use full parser if available
+ if (fullParserAvailable && validateMethod != null) {
+ return try {
+ val result = validateMethod!!.invoke(nanoDSLClass!!.kotlin.objectInstance, source)
+ convertValidationResult(result)
+ } catch (e: Exception) {
+ logger.warn(e) { "Full validation failed, falling back to basic validation" }
+ performBasicValidation(source)
+ }
+ }
+
+ return performBasicValidation(source)
+ }
+
+ actual fun parse(source: String): NanoDSLParseResult {
+ val validationResult = validate(source)
+ if (!validationResult.isValid) {
+ return NanoDSLParseResult.Failure(validationResult.errors)
+ }
+
+ // Try to use full parser if available
+ if (fullParserAvailable && toJsonMethod != null) {
+ return try {
+ val irJson = toJsonMethod!!.invoke(nanoDSLClass!!.kotlin.objectInstance, source, true) as String
+ NanoDSLParseResult.Success(irJson)
+ } catch (e: Exception) {
+ logger.warn(e) { "Full parsing failed: ${e.message}" }
+ val errorMessage = e.cause?.message ?: e.message ?: "Unknown parse error"
+ NanoDSLParseResult.Failure(listOf(ValidationError(errorMessage, 0)))
+ }
+ }
+
+ // Fallback: return a minimal IR wrapper
+ return NanoDSLParseResult.Success(createMinimalIR(source))
+ }
+
+ /**
+ * Convert xuiper-ui's ValidationResult to our NanoDSLValidationResult via reflection
+ */
+ private fun convertValidationResult(result: Any): NanoDSLValidationResult {
+ return try {
+ val isValid = result::class.java.getMethod("isValid").invoke(result) as Boolean
+ val errorsField = result::class.java.getMethod("getErrors").invoke(result) as List<*>
+ val warningsField = result::class.java.getMethod("getWarnings").invoke(result) as List<*>
+
+ val errors = errorsField.mapNotNull { error ->
+ if (error == null) return@mapNotNull null
+ val message = error::class.java.getMethod("getMessage").invoke(error) as String
+ val line = error::class.java.getMethod("getLine").invoke(error) as Int
+ ValidationError(message, line)
+ }
+
+ val warnings = warningsField.mapNotNull { warning ->
+ if (warning == null) return@mapNotNull null
+ warning::class.java.getMethod("getMessage").invoke(warning) as String
+ }
+
+ NanoDSLValidationResult(isValid, errors, warnings)
+ } catch (e: Exception) {
+ logger.warn(e) { "Failed to convert validation result" }
+ NanoDSLValidationResult(isValid = true) // Assume valid if conversion fails
+ }
+ }
+
+ /**
+ * Basic validation without full AST parsing
+ */
+ private fun performBasicValidation(source: String): NanoDSLValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+ val lines = source.lines()
+
+ // Check for component definition
+ val hasComponentDef = lines.any { it.trim().startsWith("component ") && it.trim().endsWith(":") }
+ if (!hasComponentDef) {
+ errors.add(ValidationError(
+ "Missing component definition. Expected 'component Name:'",
+ 0,
+ "Add 'component YourComponentName:' at the start"
+ ))
+ }
+
+ // Check indentation consistency
+ var expectedIndent = 0
+ for ((lineNum, line) in lines.withIndex()) {
+ if (line.isBlank()) continue
+
+ val indent = line.takeWhile { it == ' ' }.length
+
+ // Check for tabs (not allowed)
+ if (line.contains('\t')) {
+ errors.add(ValidationError(
+ "Tabs are not allowed. Use 4 spaces for indentation.",
+ lineNum,
+ "Replace tabs with 4 spaces"
+ ))
+ }
+
+ // Check for odd indentation (should be multiple of 4)
+ if (indent % 4 != 0) {
+ warnings.add("Line ${lineNum + 1}: Indentation should be a multiple of 4 spaces")
+ }
+ }
+
+ // Check for known components
+ val knownComponents = setOf(
+ "VStack", "HStack", "Card", "Text", "Button", "Input", "Image",
+ "Badge", "Checkbox", "Toggle", "Select", "List", "Grid",
+ "Spacer", "Divider", "Form", "Section"
+ )
+
+ for ((lineNum, line) in lines.withIndex()) {
+ val trimmed = line.trim()
+
+ // Check for component-like patterns
+ val componentMatch = Regex("""^(\w+)(?:\(.*\))?:\s*$""").find(trimmed)
+ if (componentMatch != null) {
+ val componentName = componentMatch.groupValues[1]
+ if (componentName !in knownComponents &&
+ componentName != "component" &&
+ componentName != "state" &&
+ componentName != "if" &&
+ componentName != "for" &&
+ componentName != "on_click" &&
+ componentName != "content") {
+ warnings.add("Line ${lineNum + 1}: Unknown component '$componentName'. Consider using standard components.")
+ }
+ }
+ }
+
+ // Check for unclosed blocks (basic bracket matching)
+ var parenCount = 0
+ var braceCount = 0
+ for ((lineNum, line) in lines.withIndex()) {
+ for (char in line) {
+ when (char) {
+ '(' -> parenCount++
+ ')' -> parenCount--
+ '{' -> braceCount++
+ '}' -> braceCount--
+ }
+ }
+ }
+
+ if (parenCount != 0) {
+ errors.add(ValidationError(
+ "Unbalanced parentheses",
+ 0,
+ "Check for missing '(' or ')'"
+ ))
+ }
+
+ if (braceCount != 0) {
+ errors.add(ValidationError(
+ "Unbalanced braces",
+ 0,
+ "Check for missing '{' or '}'"
+ ))
+ }
+
+ return NanoDSLValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors,
+ warnings = warnings
+ )
+ }
+
+ /**
+ * Create a minimal IR JSON when full parser is not available
+ */
+ private fun createMinimalIR(source: String): String {
+ // Extract component name from source
+ val componentNameMatch = Regex("""component\s+(\w+):""").find(source)
+ val componentName = componentNameMatch?.groupValues?.get(1) ?: "UnknownComponent"
+
+ // Escape the source for JSON
+ val escapedSource = source
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+ return """{"type":"Component","props":{"name":"$componentName"},"source":"$escapedSource"}"""
+ }
+}
+
diff --git a/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.wasmJs.kt b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.wasmJs.kt
new file mode 100644
index 0000000000..fdd16d45c5
--- /dev/null
+++ b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/parser/NanoDSLValidator.wasmJs.kt
@@ -0,0 +1,103 @@
+package cc.unitmesh.agent.parser
+
+/**
+ * WASM JavaScript implementation of NanoDSLValidator.
+ *
+ * Performs basic syntax validation without full AST parsing.
+ * Full parsing with IR generation is only available on JVM platforms.
+ */
+actual class NanoDSLValidator actual constructor() {
+
+ actual fun validate(source: String): NanoDSLValidationResult {
+ if (source.isBlank()) {
+ return NanoDSLValidationResult(
+ isValid = false,
+ errors = listOf(ValidationError("Empty source code", 0))
+ )
+ }
+
+ return performBasicValidation(source)
+ }
+
+ actual fun parse(source: String): NanoDSLParseResult {
+ val validationResult = validate(source)
+ if (!validationResult.isValid) {
+ return NanoDSLParseResult.Failure(validationResult.errors)
+ }
+
+ // WASM platform: return source-only IR
+ return NanoDSLParseResult.Success(createSourceOnlyIR(source))
+ }
+
+ private fun performBasicValidation(source: String): NanoDSLValidationResult {
+ val errors = mutableListOf()
+ val warnings = mutableListOf()
+ val lines = source.lines()
+
+ // Check for component definition
+ val hasComponentDef = lines.any { it.trim().startsWith("component ") && it.trim().endsWith(":") }
+ if (!hasComponentDef) {
+ errors.add(ValidationError(
+ "Missing component definition. Expected 'component Name:'",
+ 0,
+ "Add 'component YourComponentName:' at the start"
+ ))
+ }
+
+ // Check indentation
+ for ((lineNum, line) in lines.withIndex()) {
+ if (line.isBlank()) continue
+
+ if (line.contains('\t')) {
+ errors.add(ValidationError(
+ "Tabs are not allowed. Use 4 spaces for indentation.",
+ lineNum,
+ "Replace tabs with 4 spaces"
+ ))
+ }
+ }
+
+ // Check for unclosed blocks
+ var parenCount = 0
+ var braceCount = 0
+ for (line in lines) {
+ for (char in line) {
+ when (char) {
+ '(' -> parenCount++
+ ')' -> parenCount--
+ '{' -> braceCount++
+ '}' -> braceCount--
+ }
+ }
+ }
+
+ if (parenCount != 0) {
+ errors.add(ValidationError("Unbalanced parentheses", 0))
+ }
+
+ if (braceCount != 0) {
+ errors.add(ValidationError("Unbalanced braces", 0))
+ }
+
+ return NanoDSLValidationResult(
+ isValid = errors.isEmpty(),
+ errors = errors,
+ warnings = warnings
+ )
+ }
+
+ private fun createSourceOnlyIR(source: String): String {
+ val componentNameMatch = Regex("""component\s+(\w+):""").find(source)
+ val componentName = componentNameMatch?.groupValues?.get(1) ?: "UnknownComponent"
+
+ val escapedSource = source
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+
+ return """{"type":"Component","props":{"name":"$componentName"},"source":"$escapedSource"}"""
+ }
+}
+
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaNanoDSLBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaNanoDSLBubble.kt
new file mode 100644
index 0000000000..45d564acb8
--- /dev/null
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaNanoDSLBubble.kt
@@ -0,0 +1,163 @@
+package cc.unitmesh.devins.idea.components.timeline
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import cc.unitmesh.agent.render.TimelineItem
+import cc.unitmesh.devins.ui.compose.theme.AutoDevColors
+import com.intellij.openapi.project.Project
+import org.jetbrains.jewel.foundation.theme.JewelTheme
+import org.jetbrains.jewel.ui.component.Text
+
+/**
+ * NanoDSL timeline bubble - displays generated NanoDSL code with optional preview.
+ * Uses Jewel theming for IntelliJ look and feel.
+ */
+@Composable
+fun IdeaNanoDSLBubble(
+ item: TimelineItem.NanoDSLItem,
+ project: Project? = null,
+ modifier: Modifier = Modifier
+) {
+ // TODO: Add live preview toggle when NanoRenderer integration is ready
+ // var showPreview by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ ) {
+ // Bubble container
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(AutoDevColors.Energy.aiDim)
+ .padding(12.dp)
+ ) {
+ Column {
+ // Header row with component name and status
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "π¨",
+ style = JewelTheme.defaultTextStyle.copy(fontSize = 16.sp)
+ )
+ Text(
+ text = item.componentName ?: "Generated UI",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp
+ )
+ )
+ if (item.generationAttempts > 1) {
+ Box(
+ modifier = Modifier
+ .background(
+ AutoDevColors.Amber.c400.copy(alpha = 0.2f),
+ RoundedCornerShape(4.dp)
+ )
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = "${item.generationAttempts} attempts",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontSize = 10.sp,
+ color = AutoDevColors.Amber.c600
+ )
+ )
+ }
+ }
+ }
+
+ // Validity indicator
+ Text(
+ text = if (item.isValid) "β
Valid" else "β οΈ Invalid",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontSize = 12.sp,
+ color = if (item.isValid) AutoDevColors.Green.c400
+ else AutoDevColors.Red.c400
+ )
+ )
+ }
+
+ // Warnings
+ if (item.warnings.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ item.warnings.forEach { warning ->
+ Text(
+ text = "β $warning",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontSize = 11.sp,
+ color = AutoDevColors.Red.c400.copy(alpha = 0.8f)
+ )
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Code display
+ val lines = item.source.lines()
+ val maxLineNumWidth = lines.size.toString().length
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(4.dp))
+ .background(JewelTheme.globalColors.panelBackground)
+ .padding(8.dp)
+ ) {
+ Column {
+ lines.forEachIndexed { index, line ->
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = (index + 1).toString().padStart(maxLineNumWidth, ' '),
+ style = JewelTheme.defaultTextStyle.copy(
+ color = JewelTheme.globalColors.text.info.copy(alpha = 0.4f),
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp
+ ),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(
+ text = line,
+ style = JewelTheme.defaultTextStyle.copy(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp
+ )
+ )
+ }
+ }
+ }
+ }
+
+ // Footer with line count
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${lines.size} lines of NanoDSL code",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontSize = 10.sp,
+ color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f)
+ )
+ )
+ }
+ }
+ }
+}
+
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt
index 29b3079f8e..40f8b2e36c 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt
@@ -86,6 +86,9 @@ fun IdeaTimelineItemView(
onCancel = onProcessCancel
)
}
+ is TimelineItem.NanoDSLItem -> {
+ IdeaNanoDSLBubble(item, project)
+ }
}
}
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compose/IdeaComposeEffects.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compose/IdeaComposeEffects.kt
index 4b316dd246..c9fb94bb32 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compose/IdeaComposeEffects.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compose/IdeaComposeEffects.kt
@@ -1,6 +1,16 @@
package cc.unitmesh.devins.idea.compose
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import cc.unitmesh.devins.idea.services.CoroutineScopeHolder
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
@@ -9,6 +19,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import org.jetbrains.jewel.foundation.theme.JewelTheme
+import javax.swing.Timer
/**
* Compose effect utilities that avoid ClassLoader conflicts with IntelliJ's bundled Compose.
@@ -167,3 +179,49 @@ fun collectAsIdeaStateOrNull(
return state
}
+/**
+ * Custom CircularProgressIndicator that avoids ClassLoader conflicts.
+ *
+ * Jewel's CircularProgressIndicator uses Dispatchers.getDefault() internally,
+ * which causes ClassLoader conflicts between the plugin's coroutines and
+ * IntelliJ's bundled Compose runtime.
+ *
+ * This implementation uses a Swing Timer for animation instead of coroutines.
+ */
+@Composable
+fun IdeaCircularProgressIndicator(
+ modifier: Modifier = Modifier,
+ size: Dp = 16.dp,
+ strokeWidth: Dp = 2.dp,
+ color: Color? = null
+) {
+ var rotation by remember { mutableStateOf(0f) }
+
+ // Use Swing Timer instead of coroutines to avoid ClassLoader conflicts
+ DisposableEffect(Unit) {
+ val timer = Timer(16) { // ~60fps
+ rotation = (rotation + 6f) % 360f
+ }
+ timer.start()
+ onDispose { timer.stop() }
+ }
+
+ val progressColor = color ?: JewelTheme.globalColors.text.info
+
+ Canvas(modifier = modifier.size(size)) {
+ val strokePx = strokeWidth.toPx()
+ val arcSize = Size(this.size.width - strokePx, this.size.height - strokePx)
+ val topLeft = Offset(strokePx / 2, strokePx / 2)
+
+ // Draw spinning arc
+ drawArc(
+ color = progressColor,
+ startAngle = rotation,
+ sweepAngle = 270f,
+ useCenter = false,
+ topLeft = topLeft,
+ size = arcSize,
+ style = Stroke(width = strokePx, cap = StrokeCap.Round)
+ )
+ }
+}
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt
index 4c2a93f8b2..6587d49b57 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.unitmesh.agent.config.*
import cc.unitmesh.agent.mcp.McpServerConfig
+import cc.unitmesh.devins.idea.compose.IdeaCircularProgressIndicator
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
import cc.unitmesh.devins.idea.compose.rememberIdeaCoroutineScope
import cc.unitmesh.devins.idea.services.IdeaToolConfigService
@@ -642,7 +643,7 @@ private fun McpToolsTab(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
- CircularProgressIndicator(modifier = Modifier.size(16.dp))
+ IdeaCircularProgressIndicator(modifier = Modifier, size = 16.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("Loading MCP tools...")
}
@@ -709,7 +710,7 @@ private fun McpServerHeader(
}
if (serverState?.isLoading == true) {
- CircularProgressIndicator(modifier = Modifier.size(14.dp))
+ IdeaCircularProgressIndicator(modifier = Modifier, size = 14.dp)
Spacer(modifier = Modifier.width(8.dp))
}
@@ -859,7 +860,7 @@ private fun McpServersTab(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isReloading) {
- CircularProgressIndicator(modifier = Modifier.size(14.dp))
+ IdeaCircularProgressIndicator(modifier = Modifier, size = 14.dp)
Text(
text = "Loading...",
style = JewelTheme.defaultTextStyle.copy(
@@ -975,7 +976,7 @@ private fun McpServersTab(
enabled = !isReloading && errorMessage == null
) {
if (isReloading) {
- CircularProgressIndicator(modifier = Modifier.size(14.dp))
+ IdeaCircularProgressIndicator(modifier = Modifier, size = 14.dp)
Spacer(modifier = Modifier.width(4.dp))
} else {
Icon(
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt
index 8dba239e26..41663cca59 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt
@@ -536,6 +536,35 @@ class JewelRenderer : BaseRenderer() {
)
}
+ /**
+ * Render generated NanoDSL UI code.
+ * On IntelliJ, this adds a NanoDSLItem to the timeline with optional live preview.
+ */
+ override fun renderNanoDSL(
+ source: String,
+ irJson: String?,
+ metadata: Map
+ ) {
+ // Extract component name from source if not in metadata
+ val componentName = metadata["componentName"]
+ ?: Regex("""component\s+(\w+):""").find(source)?.groupValues?.get(1)
+
+ val attempts = metadata["attempts"]?.toIntOrNull() ?: 1
+ val isValid = metadata["isValid"]?.toBoolean() ?: true
+ val warnings = metadata["warnings"]?.split(";")?.filter { it.isNotBlank() } ?: emptyList()
+
+ addTimelineItem(
+ TimelineItem.NanoDSLItem(
+ source = source,
+ irJson = irJson,
+ componentName = componentName,
+ generationAttempts = attempts,
+ isValid = isValid,
+ warnings = warnings
+ )
+ )
+ }
+
override fun updateTokenInfo(tokenInfo: TokenInfo) {
_lastMessageTokenInfo = tokenInfo
_totalTokenInfo.update { current ->
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt
index 2109d0727f..e4dcc49e7b 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt
@@ -8,12 +8,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import cc.unitmesh.devins.idea.compose.IdeaCircularProgressIndicator
import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect
import cc.unitmesh.devins.ui.compose.theme.AutoDevColors
import com.intellij.openapi.Disposable
import com.intellij.ui.jcef.JBCefApp
import org.jetbrains.jewel.foundation.theme.JewelTheme
-import org.jetbrains.jewel.ui.component.CircularProgressIndicator
import org.jetbrains.jewel.ui.component.Text
/**
@@ -97,7 +97,7 @@ private fun LoadingOverlay() {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
- CircularProgressIndicator()
+ IdeaCircularProgressIndicator()
}
}
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt
index dec58603f4..e610abd5ab 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt
@@ -25,6 +25,7 @@ import cc.unitmesh.agent.codereview.ModifiedCodeRange
import cc.unitmesh.agent.linter.LintFileResult
import cc.unitmesh.agent.linter.LintIssue
import cc.unitmesh.agent.linter.LintSeverity
+import cc.unitmesh.devins.idea.compose.IdeaCircularProgressIndicator
import cc.unitmesh.devins.idea.renderer.sketch.IdeaSketchRenderer
import cc.unitmesh.devins.ui.compose.agent.codereview.AnalysisStage
import cc.unitmesh.devins.ui.compose.agent.codereview.CodeReviewState
@@ -88,7 +89,7 @@ internal fun IdeaAnalysisHeader(stage: AnalysisStage, hasDiffFiles: Boolean, onS
if (stage != AnalysisStage.IDLE) {
Box(modifier = Modifier.background(statusColor.copy(alpha = 0.15f), RoundedCornerShape(4.dp)).padding(horizontal = 6.dp, vertical = 2.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
- if (stage != AnalysisStage.COMPLETED && stage != AnalysisStage.ERROR) CircularProgressIndicator()
+ if (stage != AnalysisStage.COMPLETED && stage != AnalysisStage.ERROR) IdeaCircularProgressIndicator()
Text(statusText, style = JewelTheme.defaultTextStyle.copy(color = statusColor, fontSize = 11.sp, fontWeight = FontWeight.Medium))
}
}
@@ -190,7 +191,7 @@ internal fun IdeaSuggestedFixesSection(fixOutput: String, isGenerating: Boolean,
IdeaCollapsibleCard("Fix Generation", isExpanded, { isExpanded = it }, isGenerating, {
if (isGenerating) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
- CircularProgressIndicator()
+ IdeaCircularProgressIndicator()
IdeaBadge("Generating...", AutoDevColors.Indigo.c400)
}
} else if (fixOutput.isNotEmpty()) IdeaBadge("Complete", AutoDevColors.Green.c400)
@@ -202,7 +203,7 @@ internal fun IdeaSuggestedFixesSection(fixOutput: String, isGenerating: Boolean,
parentDisposable = parentDisposable,
modifier = Modifier.fillMaxWidth()
)
- isGenerating -> Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ isGenerating -> Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { IdeaCircularProgressIndicator() }
else -> Text("No fixes generated yet.", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info, fontSize = 12.sp))
}
}
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt
index a20af8b113..5d906512d1 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import cc.unitmesh.devins.idea.compose.IdeaCircularProgressIndicator
import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons
import cc.unitmesh.devins.ui.compose.agent.codereview.CommitInfo
import cc.unitmesh.devins.ui.compose.theme.AutoDevColors
@@ -52,7 +53,7 @@ internal fun CommitListPanel(
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
+ IdeaCircularProgressIndicator()
}
} else if (commits.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -273,7 +274,7 @@ internal fun IdeaIssueIndicator(
) {
when {
commit.isLoadingIssue -> {
- CircularProgressIndicator(modifier = Modifier.size(20.dp))
+ IdeaCircularProgressIndicator(modifier = Modifier, size = 20.dp)
}
commit.issueInfo != null -> {
IssueInfoIndicator(
diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt
index e74cf51b07..c511f2492a 100644
--- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt
+++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt
@@ -797,6 +797,52 @@ private fun ChatMessageItem(item: TimelineItem) {
}
}
}
+
+ is TimelineItem.NanoDSLItem -> {
+ // NanoDSL item display
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(AutoDevColors.Energy.aiDim)
+ .padding(8.dp)
+ ) {
+ Column {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "π¨",
+ style = JewelTheme.defaultTextStyle
+ )
+ Text(
+ text = item.componentName ?: "Generated UI",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 12.sp
+ )
+ )
+ if (item.generationAttempts > 1) {
+ Text(
+ text = "(${item.generationAttempts} attempts)",
+ style = JewelTheme.defaultTextStyle.copy(
+ color = AutoDevColors.Neutral.c400,
+ fontSize = 11.sp
+ )
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = item.source.take(300) + if (item.source.length > 300) "..." else "",
+ style = JewelTheme.defaultTextStyle.copy(
+ fontSize = 11.sp,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ )
+ )
+ }
+ }
+ }
}
}
diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt
index 69dcd043e9..4bf7997813 100644
--- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt
+++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt
@@ -225,6 +225,17 @@ fun RenderMessageItem(
output = timelineItem.output
)
}
+
+ is TimelineItem.NanoDSLItem -> {
+ NanoDSLTimelineItem(
+ source = timelineItem.source,
+ irJson = timelineItem.irJson,
+ componentName = timelineItem.componentName,
+ generationAttempts = timelineItem.generationAttempts,
+ isValid = timelineItem.isValid,
+ warnings = timelineItem.warnings
+ )
+ }
}
}
@@ -366,3 +377,128 @@ fun TaskCompletedItem(
}
}
+/**
+ * NanoDSL Timeline Item - displays generated NanoDSL code
+ * @param irJson The IR JSON representation (reserved for future preview feature)
+ */
+@Composable
+fun NanoDSLTimelineItem(
+ source: String,
+ @Suppress("unused") irJson: String?, // Reserved for future live preview feature
+ componentName: String?,
+ generationAttempts: Int,
+ isValid: Boolean,
+ warnings: List,
+ modifier: Modifier = Modifier
+) {
+ // TODO: Add live preview toggle when NanoRenderer integration is ready
+ // var showPreview by remember { mutableStateOf(false) }
+
+ Surface(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp)
+ ) {
+ // Header row with component name and status
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "π¨",
+ fontSize = 16.sp
+ )
+ Text(
+ text = componentName ?: "Generated UI",
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp
+ )
+ if (generationAttempts > 1) {
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f)
+ ) {
+ Text(
+ text = "$generationAttempts attempts",
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ }
+ }
+
+ // Validity indicator
+ Text(
+ text = if (isValid) "β
Valid" else "β οΈ Invalid",
+ fontSize = 12.sp,
+ color = if (isValid) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.error
+ )
+ }
+
+ // Warnings
+ if (warnings.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ warnings.forEach { warning ->
+ Text(
+ text = "β $warning",
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Code display
+ val lines = source.lines()
+ val maxLineNumWidth = lines.size.toString().length
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(4.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(modifier = Modifier.padding(8.dp)) {
+ // Line numbers and code
+ lines.forEachIndexed { index, line ->
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = (index + 1).toString().padStart(maxLineNumWidth, ' '),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(
+ text = line,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp
+ )
+ }
+ }
+ }
+ }
+
+ // Footer with line count
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${lines.size} lines of NanoDSL code",
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ )
+ }
+ }
+}
+
diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt
index 931e4e1c5f..1bd3a97ee3 100644
--- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt
+++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt
@@ -559,6 +559,35 @@ class ComposeRenderer : BaseRenderer() {
// For now, just use error rendering since JS renderer doesn't have this method yet
}
+ /**
+ * Render generated NanoDSL UI code.
+ * On Compose, this adds a NanoDSLItem to the timeline with optional live preview.
+ */
+ override fun renderNanoDSL(
+ source: String,
+ irJson: String?,
+ metadata: Map
+ ) {
+ // Extract component name from source if not in metadata
+ val componentName = metadata["componentName"]
+ ?: Regex("""component\s+(\w+):""").find(source)?.groupValues?.get(1)
+
+ val attempts = metadata["attempts"]?.toIntOrNull() ?: 1
+ val isValid = metadata["isValid"]?.toBoolean() ?: true
+ val warnings = metadata["warnings"]?.split(";")?.filter { it.isNotBlank() } ?: emptyList()
+
+ _timeline.add(
+ TimelineItem.NanoDSLItem(
+ source = source,
+ irJson = irJson,
+ componentName = componentName,
+ generationAttempts = attempts,
+ isValid = isValid,
+ warnings = warnings
+ )
+ )
+ }
+
// Public methods for UI interaction
fun addUserMessage(content: String) {
_timeline.add(
@@ -852,6 +881,11 @@ class ComposeRenderer : BaseRenderer() {
// Live terminal items are not persisted (they're runtime-only)
null
}
+
+ is TimelineItem.NanoDSLItem -> {
+ // NanoDSL items are not persisted (they're runtime-only, can be regenerated)
+ null
+ }
}
}
@@ -1055,6 +1089,11 @@ class ComposeRenderer : BaseRenderer() {
}
is LiveTerminalItem -> null
+
+ is TimelineItem.NanoDSLItem -> {
+ // NanoDSL items are not persisted as messages
+ null
+ }
}
}
}
diff --git a/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts
index f9e6dcd308..69f655527d 100644
--- a/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts
+++ b/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts
@@ -12,6 +12,7 @@
import {cc} from "autodev-mpp-core/autodev-mpp-core";
import JsCodingAgentRenderer = cc.unitmesh.agent.JsCodingAgentRenderer;
import JsPlanSummaryData = cc.unitmesh.agent.JsPlanSummaryData;
+import JsNanoDSLData = cc.unitmesh.agent.JsNanoDSLData;
export abstract class BaseRenderer implements JsCodingAgentRenderer {
// Required by Kotlin JS export interface
@@ -145,6 +146,16 @@ export abstract class BaseRenderer implements JsCodingAgentRenderer {
// Default: no-op, subclasses can override
}
+ /**
+ * Render generated NanoDSL UI code.
+ * Default implementation - subclasses can override for custom rendering.
+ *
+ * @param data The NanoDSL rendering data including source, IR, and metadata
+ */
+ renderNanoDSL(data: JsNanoDSLData): void {
+ // Default: no-op, subclasses can override
+ }
+
/**
* Common implementation for LLM response start
*/
diff --git a/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts
index af8c09800b..c1d030a173 100644
--- a/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts
+++ b/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts
@@ -13,6 +13,7 @@ import { semanticChalk, dividers } from '../../design-system/theme-helpers.js';
import { BaseRenderer } from './BaseRenderer.js';
import { cc } from 'autodev-mpp-core/autodev-mpp-core';
import JsPlanSummaryData = cc.unitmesh.agent.JsPlanSummaryData;
+import JsNanoDSLData = cc.unitmesh.agent.JsNanoDSLData;
/**
* CliRenderer extends BaseRenderer and implements the unified JsCodingAgentRenderer interface
@@ -595,5 +596,90 @@ export class CliRenderer extends BaseRenderer {
console.log(semanticChalk.accent(dividers.solid(50)));
console.log();
}
+
+ /**
+ * Render generated NanoDSL UI code with syntax highlighting
+ */
+ renderNanoDSL(data: JsNanoDSLData): void {
+ const { source, componentName, generationAttempts, isValid, warnings } = data;
+
+ console.log();
+ console.log(semanticChalk.accentBold('π¨ Generated NanoDSL UI'));
+
+ // Header with component info
+ const nameDisplay = componentName || 'UI Component';
+ const attemptsInfo = generationAttempts > 1 ? ` (${generationAttempts} attempts)` : '';
+ const validityIcon = isValid ? 'β
' : 'β οΈ';
+
+ console.log(
+ `${validityIcon} ` +
+ chalk.bold(nameDisplay) +
+ semanticChalk.muted(attemptsInfo)
+ );
+
+ // Show warnings if any
+ if (warnings && warnings.length > 0) {
+ for (const warning of warnings) {
+ console.log(semanticChalk.warning(` β ${warning}`));
+ }
+ }
+
+ console.log(dividers.solid(60));
+
+ // Display code with line numbers and syntax highlighting
+ const lines = source.split('\n');
+ const maxLineNumWidth = String(lines.length).length;
+
+ lines.forEach((line, index) => {
+ const lineNumber = String(index + 1).padStart(maxLineNumWidth, ' ');
+ const highlightedLine = this.highlightNanoDSL(line);
+ console.log(semanticChalk.muted(`${lineNumber} β `) + highlightedLine);
+ });
+
+ console.log(dividers.solid(60));
+ console.log(semanticChalk.muted(`${lines.length} lines of NanoDSL code`));
+ console.log();
+ }
+
+ /**
+ * Apply syntax highlighting to NanoDSL code
+ */
+ private highlightNanoDSL(line: string): string {
+ // Keywords
+ const keywords = ['component', 'state', 'if', 'for', 'in', 'on_click', 'content'];
+ // Components
+ const components = [
+ 'VStack', 'HStack', 'Card', 'Text', 'Button', 'Input', 'Image',
+ 'Badge', 'Checkbox', 'Toggle', 'Select', 'List', 'Grid',
+ 'Spacer', 'Divider', 'Form', 'Section', 'Navigate', 'ShowToast', 'Fetch'
+ ];
+
+ let result = line;
+
+ // Highlight strings (quoted text)
+ result = result.replace(/"([^"]*)"/g, (match) => semanticChalk.success(match));
+
+ // Highlight keywords
+ for (const keyword of keywords) {
+ const regex = new RegExp(`\\b(${keyword})\\b`, 'g');
+ result = result.replace(regex, (match) => semanticChalk.primary(match));
+ }
+
+ // Highlight components
+ for (const comp of components) {
+ const regex = new RegExp(`\\b(${comp})\\b`, 'g');
+ result = result.replace(regex, (match) => semanticChalk.accent(match));
+ }
+
+ // Highlight types (int, str, bool)
+ result = result.replace(/\b(int|str|bool|float)\b/g, (match) => semanticChalk.warning(match));
+
+ // Highlight state references
+ result = result.replace(/\bstate\.(\w+)/g, (match, varName) =>
+ semanticChalk.muted('state.') + chalk.cyan(varName)
+ );
+
+ return result;
+ }
}
diff --git a/mpp-ui/src/jsMain/typescript/agents/render/ServerRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/ServerRenderer.ts
index 8c87b7b898..c6f9ea671a 100644
--- a/mpp-ui/src/jsMain/typescript/agents/render/ServerRenderer.ts
+++ b/mpp-ui/src/jsMain/typescript/agents/render/ServerRenderer.ts
@@ -8,6 +8,8 @@
import { semanticChalk } from '../../design-system/theme-helpers.js';
import type { AgentEvent, AgentStepInfo, AgentEditInfo } from '../ServerAgentClient.js';
import { BaseRenderer } from './BaseRenderer.js';
+import { cc } from 'autodev-mpp-core/autodev-mpp-core';
+import JsNanoDSLData = cc.unitmesh.agent.JsNanoDSLData;
/**
* ServerRenderer extends BaseRenderer and implements the unified JsCodingAgentRenderer interface
@@ -500,6 +502,45 @@ export class ServerRenderer extends BaseRenderer {
console.log('');
}
+ /**
+ * Render generated NanoDSL UI code
+ */
+ renderNanoDSL(data: JsNanoDSLData): void {
+ const { source, componentName, generationAttempts, isValid, warnings } = data;
+
+ console.log('');
+ console.log(semanticChalk.accentBold('π¨ Generated NanoDSL UI'));
+
+ // Header with component info
+ const nameDisplay = componentName || 'UI Component';
+ const attemptsInfo = generationAttempts > 1 ? ` (${generationAttempts} attempts)` : '';
+ const validityIcon = isValid ? 'β
' : 'β οΈ';
+
+ console.log(`${validityIcon} ${nameDisplay}${attemptsInfo}`);
+
+ // Show warnings if any
+ if (warnings && warnings.length > 0) {
+ for (const warning of warnings) {
+ console.log(semanticChalk.warning(` β ${warning}`));
+ }
+ }
+
+ console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
+
+ // Display code with line numbers
+ const lines = source.split('\n');
+ const maxLineNumWidth = String(lines.length).length;
+
+ lines.forEach((line, index) => {
+ const lineNumber = String(index + 1).padStart(maxLineNumWidth, ' ');
+ console.log(semanticChalk.muted(`${lineNumber} β `) + line);
+ });
+
+ console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
+ console.log(semanticChalk.muted(`${lines.length} lines of NanoDSL code`));
+ console.log('');
+ }
+
// ============================================================================
// Server-Specific Helper Methods
// ============================================================================
diff --git a/mpp-ui/src/jsMain/typescript/agents/render/TuiRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/TuiRenderer.ts
index b3e416f8c8..07e67aef9b 100644
--- a/mpp-ui/src/jsMain/typescript/agents/render/TuiRenderer.ts
+++ b/mpp-ui/src/jsMain/typescript/agents/render/TuiRenderer.ts
@@ -13,6 +13,8 @@
import type { ModeContext } from '../../modes';
import type { Message } from '../../ui/App.js';
import {BaseRenderer} from "./BaseRenderer.js";
+import { cc } from 'autodev-mpp-core/autodev-mpp-core';
+import JsNanoDSLData = cc.unitmesh.agent.JsNanoDSLData;
/**
* TUI ζΈ²ζε¨
@@ -294,6 +296,33 @@ export class TuiRenderer extends BaseRenderer {
this.context.addMessage(systemMessage);
}
+ /**
+ * Render generated NanoDSL UI code
+ */
+ renderNanoDSL(data: JsNanoDSLData): void {
+ const { source, componentName, generationAttempts, isValid, warnings } = data;
+
+ const nameDisplay = componentName || 'UI Component';
+ const attemptsInfo = generationAttempts > 1 ? ` (${generationAttempts} attempts)` : '';
+ const validityIcon = isValid ? 'β
' : 'β οΈ';
+
+ let message = `π¨ **Generated NanoDSL UI**\n\n`;
+ message += `${validityIcon} **${nameDisplay}**${attemptsInfo}\n\n`;
+
+ // Show warnings if any
+ if (warnings && warnings.length > 0) {
+ for (const warning of warnings) {
+ message += `β ${warning}\n`;
+ }
+ message += '\n';
+ }
+
+ // Display code
+ message += '```nanodsl\n' + source + '\n```';
+
+ this.renderSystemMessage(message);
+ }
+
/**
* εΌΊεΆεζ’
*/
diff --git a/mpp-vscode/src/bridge/mpp-core.ts b/mpp-vscode/src/bridge/mpp-core.ts
index 4bf9efbe48..c76be3b96a 100644
--- a/mpp-vscode/src/bridge/mpp-core.ts
+++ b/mpp-vscode/src/bridge/mpp-core.ts
@@ -421,6 +421,24 @@ export class VSCodeRenderer {
});
}
+ /**
+ * Render generated NanoDSL UI code
+ * Posts a nanodsl message to the webview for rendering
+ */
+ renderNanoDSL(source: string, irJson?: string | null, metadata?: Record): void {
+ this.chatProvider.postMessage({
+ type: 'nanodsl',
+ data: {
+ source,
+ irJson,
+ componentName: metadata?.componentName,
+ attempts: metadata?.attempts ? parseInt(metadata.attempts) : 1,
+ isValid: metadata?.isValid === 'true',
+ warnings: metadata?.warnings?.split(';').filter(Boolean) || []
+ }
+ });
+ }
+
forceStop(): void {
this.chatProvider.postMessage({
type: 'taskComplete',