diff --git a/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.android.kt b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.android.kt new file mode 100644 index 0000000000..eda7cbb21c --- /dev/null +++ b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.android.kt @@ -0,0 +1,120 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +actual suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult = withContext(Dispatchers.IO) { + val process = processHandle as? Process + ?: return@withContext RunConfigResult( + success = false, + error = "Invalid process handle" + ) + + val startTime = System.currentTimeMillis() + val outputBuilder = StringBuilder() + var cancelled = false + + // Read stdout in a separate thread + val stdoutReader = Thread { + try { + process.inputStream.bufferedReader().use { reader -> + val buffer = CharArray(1024) + while (!cancelled) { + val charsRead = reader.read(buffer) + if (charsRead == -1) break + val chunk = buffer.concatToString(0, charsRead) + outputBuilder.append(chunk) + onOutput(chunk) + } + } + } catch (_: Exception) { + // Stream closed, ignore + } + }.apply { start() } + + // Read stderr in a separate thread + val stderrReader = Thread { + try { + process.errorStream.bufferedReader().use { reader -> + val buffer = CharArray(1024) + while (!cancelled) { + val charsRead = reader.read(buffer) + if (charsRead == -1) break + val chunk = buffer.concatToString(0, charsRead) + outputBuilder.append(chunk) + onOutput(chunk) + } + } + } catch (_: Exception) { + // Stream closed, ignore + } + }.apply { start() } + + try { + // Wait for process with timeout + val completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + + // Wait for readers to finish + stdoutReader.join(2000) + stderrReader.join(2000) + + if (!completed) { + onOutput("\n[INFO] Process still running after ${timeoutMs / 1000}s timeout.\n") + // Android API level check for pid() - use reflection or fallback + val pid = try { + process.javaClass.getMethod("pid").invoke(process) as? Long + } catch (_: Exception) { + null + } + onProcessStarted(pid?.toInt()) + return@withContext RunConfigResult( + success = true, + message = "Process started (may still be running)", + pid = pid?.toInt() + ) + } + + val exitCode = process.exitValue() + val executionTime = System.currentTimeMillis() - startTime + + RunConfigResult( + success = exitCode == 0, + exitCode = exitCode, + message = if (exitCode == 0) { + "Command completed successfully (${executionTime}ms)" + } else { + "Command exited with code $exitCode" + } + ) + } catch (e: kotlinx.coroutines.CancellationException) { + cancelled = true + stdoutReader.interrupt() + stderrReader.interrupt() + throw e + } +} + +actual fun killProcessPlatform(processHandle: Any?) { + val process = processHandle as? Process ?: return + try { + // Use exitValue to check if alive (throws if still running) + try { + process.exitValue() + } catch (_: IllegalThreadStateException) { + // Process is still running, destroy it + process.destroyForcibly() + } + } catch (_: Exception) { + // Ignore + } +} + +actual fun isNativeProcess(handle: Any?): Boolean = handle is Process + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/LLMRunConfigAnalyzer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/LLMRunConfigAnalyzer.kt new file mode 100644 index 0000000000..f84aa11f2d --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/LLMRunConfigAnalyzer.kt @@ -0,0 +1,312 @@ +package cc.unitmesh.agent.runconfig + +import cc.unitmesh.agent.logging.getLogger +import cc.unitmesh.agent.tool.filesystem.DefaultToolFileSystem +import cc.unitmesh.agent.tool.filesystem.ToolFileSystem +import cc.unitmesh.llm.KoogLLMService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * LLM-based Run Config Analyzer - Uses LLM streaming to analyze project and generate run configurations. + * + * This analyzer: + * 1. Gathers project context (file structure, config files) + * 2. Sends a streaming request to LLM + * 3. Parses JSON response to extract run configs + * + * Benefits over static analysis: + * - Handles complex/unconventional project structures + * - Can understand multi-module projects + * - Provides intelligent suggestions based on project context + */ +class LLMRunConfigAnalyzer( + private val projectPath: String, + private val fileSystem: ToolFileSystem = DefaultToolFileSystem(projectPath = projectPath), + private val llmService: KoogLLMService +) { + private val logger = getLogger("LLMRunConfigAnalyzer") + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Analyze project with streaming - yields progress chunks then final configs. + * + * @return Flow of AnalysisEvent - either Progress (LLM reasoning) or Complete (parsed configs) + */ + fun analyzeStreaming(): Flow = flow { + emit(AnalysisEvent.Progress("Gathering project context...")) + + // Gather project context + val context = gatherProjectContext() + emit(AnalysisEvent.Progress("Found ${context.files.size} relevant files")) + + // Build prompt + val prompt = buildAnalysisPrompt(context) + emit(AnalysisEvent.Progress("Analyzing with AI...\n")) + + // Stream LLM response + val responseBuffer = StringBuilder() + val streamedContent = StringBuilder() + var inJsonBlock = false + + try { + llmService.streamPrompt(prompt, compileDevIns = false).collect { chunk -> + responseBuffer.append(chunk) + + // Check if we're entering JSON block + if (chunk.contains("```json") || chunk.contains("```JSON")) { + inJsonBlock = true + } + + // Only emit visible text (not JSON) + if (!inJsonBlock) { + // Clean up thinking tags for display + val displayChunk = chunk + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + + if (displayChunk.isNotBlank()) { + streamedContent.append(displayChunk) + emit(AnalysisEvent.Progress(displayChunk)) + } + } + + // Check if JSON block ended + if (inJsonBlock && chunk.contains("```") && !chunk.contains("```json")) { + // JSON block might have ended, but keep inJsonBlock true to avoid further text + } + } + + // Parse final response + val fullResponse = responseBuffer.toString() + logger.debug { "LLM response:\n$fullResponse" } + + val configs = parseRunConfigs(fullResponse) + + if (configs.isNotEmpty()) { + emit(AnalysisEvent.Progress("\nโœ“ Found ${configs.size} run configurations")) + emit(AnalysisEvent.Complete(configs)) + } else { + emit(AnalysisEvent.Error("Could not parse run configurations from AI response")) + } + + } catch (e: Exception) { + logger.error { "LLM analysis failed: ${e.message}" } + emit(AnalysisEvent.Error("Analysis failed: ${e.message}")) + } + } + + /** + * Analyze project (non-streaming, for simpler use cases) + */ + suspend fun analyze(onProgress: (String) -> Unit = {}): List { + val configs = mutableListOf() + + analyzeStreaming().collect { event -> + when (event) { + is AnalysisEvent.Progress -> onProgress(event.message) + is AnalysisEvent.Complete -> configs.addAll(event.configs) + is AnalysisEvent.Error -> onProgress("Error: ${event.message}") + } + } + + return configs + } + + /** + * Gather project context for LLM analysis + */ + private suspend fun gatherProjectContext(): ProjectContext { + val files = mutableListOf() + val configContents = mutableMapOf() + + // Important config files to read + val configFiles = listOf( + "package.json", + "build.gradle.kts", "build.gradle", + "settings.gradle.kts", "settings.gradle", + "pom.xml", + "Cargo.toml", + "go.mod", + "pyproject.toml", "setup.py", "requirements.txt", + "Makefile", + "docker-compose.yml", "docker-compose.yaml", + "Dockerfile", + ".github/workflows/ci.yml" + ) + + // Get top-level file list + try { + val topFiles = fileSystem.listFiles(projectPath) + .filter { !it.startsWith(".") && it != "node_modules" && it != "build" && it != "target" } + .take(30) + files.addAll(topFiles) + } catch (e: Exception) { + logger.warn { "Failed to list files: ${e.message}" } + } + + // Read relevant config files + for (configFile in configFiles) { + val path = "$projectPath/$configFile" + if (fileSystem.exists(path)) { + try { + val content = fileSystem.readFile(path) + if (content != null) { + // Truncate large files + val truncated = if (content.length > 2000) { + content.take(2000) + "\n... (truncated)" + } else { + content + } + configContents[configFile] = truncated + } + } catch (e: Exception) { + logger.warn { "Failed to read $configFile: ${e.message}" } + } + } + } + + return ProjectContext(files, configContents) + } + + /** + * Build the analysis prompt for LLM + */ + private fun buildAnalysisPrompt(context: ProjectContext): String { + return buildString { + appendLine("You are a project analysis expert. Analyze this project and generate run configurations.") + appendLine() + appendLine("## Project Files") + appendLine(context.files.joinToString("\n") { "- $it" }) + appendLine() + + if (context.configContents.isNotEmpty()) { + appendLine("## Configuration Files") + context.configContents.forEach { (name, content) -> + appendLine() + appendLine("### $name") + appendLine("```") + appendLine(content) + appendLine("```") + } + appendLine() + } + + appendLine("## Task") + appendLine("Based on this project structure, identify all available run configurations.") + appendLine("Consider: start commands, dev/watch modes, test commands, build commands, lint/format, deploy, clean, install dependencies.") + appendLine() + appendLine("## Output Format") + appendLine("First, briefly explain what type of project this is and what commands you found.") + appendLine("Then output a JSON array with the run configurations:") + appendLine() + appendLine("```json") + appendLine("[") + appendLine(" {") + appendLine(" \"name\": \"Display name (e.g., 'Start Dev Server')\",") + appendLine(" \"command\": \"The shell command to run (e.g., 'npm run dev')\",") + appendLine(" \"type\": \"RUN|DEV|TEST|BUILD|LINT|DEPLOY|CLEAN|INSTALL|CUSTOM\",") + appendLine(" \"description\": \"Brief description of what this command does\",") + appendLine(" \"workingDir\": \".\" // Optional, relative to project root") + appendLine(" }") + appendLine("]") + appendLine("```") + appendLine() + appendLine("Important:") + appendLine("- Include the most useful commands (max 10)") + appendLine("- Mark the primary 'run' command as type RUN") + appendLine("- Use correct commands for the detected package manager (npm/yarn/pnpm)") + appendLine("- For Gradle, use './gradlew' if wrapper exists") + } + } + + /** + * Parse run configs from LLM response + */ + private fun parseRunConfigs(response: String): List { + // Extract JSON block from response + val jsonPattern = Regex("```json\\s*([\\s\\S]*?)```", RegexOption.IGNORE_CASE) + val match = jsonPattern.find(response) + + val jsonStr = match?.groupValues?.get(1)?.trim() + ?: run { + // Try to find raw JSON array + val arrayPattern = Regex("\\[\\s*\\{[\\s\\S]*?}\\s*]") + arrayPattern.find(response)?.value + } + + if (jsonStr.isNullOrBlank()) { + logger.warn { "No JSON found in LLM response" } + return emptyList() + } + + return try { + val suggestions = json.decodeFromString>(jsonStr) + suggestions.mapIndexed { index, suggestion -> + RunConfig( + id = "ai-${suggestion.name.lowercase().replace(Regex("[^a-z0-9]"), "-")}-$index", + name = suggestion.name, + type = parseRunConfigType(suggestion.type), + command = suggestion.command, + workingDir = suggestion.workingDir ?: ".", + description = suggestion.description ?: "", + source = RunConfigSource.AI_GENERATED, + isDefault = index == 0 && parseRunConfigType(suggestion.type) == RunConfigType.RUN + ) + } + } catch (e: Exception) { + logger.error { "Failed to parse JSON: ${e.message}\nJSON: $jsonStr" } + emptyList() + } + } + + private fun parseRunConfigType(type: String?): RunConfigType { + return try { + type?.uppercase()?.let { RunConfigType.valueOf(it) } ?: RunConfigType.CUSTOM + } catch (e: Exception) { + RunConfigType.CUSTOM + } + } +} + +/** + * Events emitted during analysis + */ +sealed class AnalysisEvent { + /** Progress update with message */ + data class Progress(val message: String) : AnalysisEvent() + + /** Analysis complete with configs */ + data class Complete(val configs: List) : AnalysisEvent() + + /** Analysis failed */ + data class Error(val message: String) : AnalysisEvent() +} + +/** + * Project context gathered for LLM analysis + */ +private data class ProjectContext( + val files: List, + val configContents: Map +) + +/** + * LLM suggestion structure for parsing + */ +@Serializable +private data class LLMRunConfigSuggestion( + val name: String, + val command: String, + val type: String? = null, + val description: String? = null, + val workingDir: String? = null +) + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.kt new file mode 100644 index 0000000000..5922163214 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.kt @@ -0,0 +1,32 @@ +package cc.unitmesh.agent.runconfig + +/** + * Platform-specific process output reader. + * On JVM: Reads from java.lang.Process streams + * On other platforms: Not supported (returns error result) + */ +expect suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult + +/** + * Platform-specific process killer. + * On JVM: Kills java.lang.Process + * On other platforms: No-op + */ +expect fun killProcessPlatform(processHandle: Any?) + +/** + * Platform-specific check if process handle is a native process. + * On JVM: Returns true if handle is java.lang.Process + * On other platforms: Returns false + */ +expect fun isNativeProcess(handle: Any?): Boolean + +/** + * Get current time in milliseconds (platform-specific) + */ +expect fun currentTimeMillis(): Long diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfig.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfig.kt new file mode 100644 index 0000000000..49757f4a42 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfig.kt @@ -0,0 +1,165 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.serialization.Serializable + +/** + * RunConfig - Project run configuration discovered by analyzing the codebase. + * + * Similar to IDE run configurations (IntelliJ, VS Code), this represents + * executable commands like: + * - `npm start` / `npm run dev` + * - `./gradlew run` / `./gradlew bootRun` + * - `python main.py` + * - `cargo run` + * - `go run main.go` + */ +@Serializable +data class RunConfig( + /** Unique identifier */ + val id: String, + + /** Display name shown in the menu */ + val name: String, + + /** Type of run configuration */ + val type: RunConfigType, + + /** The command to execute */ + val command: String, + + /** Working directory (relative to project root) */ + val workingDir: String = ".", + + /** Environment variables */ + val env: Map = emptyMap(), + + /** Description of what this config does */ + val description: String = "", + + /** Icon identifier for UI */ + val icon: String? = null, + + /** Whether this is the default/primary run config */ + val isDefault: Boolean = false, + + /** Whether this config is enabled */ + val enabled: Boolean = true, + + /** Source of this config (auto-detected, user-defined, etc.) */ + val source: RunConfigSource = RunConfigSource.AUTO_DETECTED +) + +/** + * Type of run configuration + */ +@Serializable +enum class RunConfigType { + /** Start the application */ + RUN, + + /** Run in development/watch mode */ + DEV, + + /** Run tests */ + TEST, + + /** Build the project */ + BUILD, + + /** Lint/format code */ + LINT, + + /** Deploy the project */ + DEPLOY, + + /** Clean build artifacts */ + CLEAN, + + /** Install dependencies */ + INSTALL, + + /** Custom command */ + CUSTOM +} + +/** + * Source of the run configuration + */ +@Serializable +enum class RunConfigSource { + /** Auto-detected from project files */ + AUTO_DETECTED, + + /** Defined by user */ + USER_DEFINED, + + /** Loaded from project config file */ + PROJECT_CONFIG, + + /** Generated by AI analysis */ + AI_GENERATED +} + +/** + * State of run config discovery/analysis + */ +@Serializable +enum class RunConfigState { + /** Not analyzed yet */ + NOT_CONFIGURED, + + /** Currently analyzing project */ + ANALYZING, + + /** Analysis complete, configs available */ + CONFIGURED, + + /** Analysis failed */ + ERROR +} + +/** + * Result of executing a run config + */ +@Serializable +data class RunConfigResult( + /** Whether execution started successfully */ + val success: Boolean, + + /** Process ID if running in background */ + val pid: Int? = null, + + /** Output message */ + val message: String = "", + + /** Error message if failed */ + val error: String? = null, + + /** Exit code if completed */ + val exitCode: Int? = null +) + +/** + * Storage format for persisting run configs in .xiuper/run-configs.json + */ +@Serializable +data class RunConfigStorage( + /** Version for format compatibility */ + val version: Int = 1, + + /** Project path this config belongs to */ + val projectPath: String, + + /** Detected project type */ + val projectType: String? = null, + + /** List of run configurations */ + val configs: List = emptyList(), + + /** ID of the default run config */ + val defaultConfigId: String? = null, + + /** Last analysis timestamp */ + val lastAnalyzedAt: Long? = null +) + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfigService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfigService.kt new file mode 100644 index 0000000000..5c36409cac --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/runconfig/RunConfigService.kt @@ -0,0 +1,489 @@ +package cc.unitmesh.agent.runconfig + +import cc.unitmesh.agent.logging.getLogger +import cc.unitmesh.agent.tool.filesystem.DefaultToolFileSystem +import cc.unitmesh.agent.tool.filesystem.ToolFileSystem +import cc.unitmesh.agent.tool.shell.DefaultShellExecutor +import cc.unitmesh.agent.tool.shell.LiveShellExecutor +import cc.unitmesh.agent.tool.shell.LiveShellSession +import cc.unitmesh.agent.tool.shell.ShellExecutionConfig +import cc.unitmesh.agent.tool.shell.ShellExecutor +import cc.unitmesh.llm.KoogLLMService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json + +/** + * RunConfigService - Manages run configurations for a project. + * + * This service: + * 1. Uses LLM to analyze project and generate run configurations (streaming) + * 2. Executes run configs via shell + * 3. Persists configs to .xiuper/run-configs.json + * + * Note: Requires LLM service for project analysis. If llmService is null, + * users must add run configs manually. + */ +class RunConfigService( + private val projectPath: String, + private val fileSystem: ToolFileSystem = DefaultToolFileSystem(projectPath = projectPath), + private val shellExecutor: ShellExecutor = DefaultShellExecutor(), + private val llmService: KoogLLMService? = null +) { + private val logger = getLogger("RunConfigService") + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + // LLM-based analyzer + private val llmAnalyzer: LLMRunConfigAnalyzer? = llmService?.let { + LLMRunConfigAnalyzer(projectPath, fileSystem, it) + } + + private val configStoragePath = "$projectPath/.xiuper/run-configs.json" + + // State + private val _state = MutableStateFlow(RunConfigState.NOT_CONFIGURED) + val state: StateFlow = _state.asStateFlow() + + private val _configs = MutableStateFlow>(emptyList()) + val configs: StateFlow> = _configs.asStateFlow() + + private val _defaultConfig = MutableStateFlow(null) + val defaultConfig: StateFlow = _defaultConfig.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning.asStateFlow() + + private val _runningConfigId = MutableStateFlow(null) + val runningConfigId: StateFlow = _runningConfigId.asStateFlow() + + /** + * Initialize - try to load saved configs or analyze project + */ + suspend fun initialize() { + // Try to load saved configs first + val loaded = loadConfigs() + if (loaded != null && loaded.configs.isNotEmpty()) { + _configs.value = loaded.configs + _defaultConfig.value = loaded.configs.find { it.id == loaded.defaultConfigId } + ?: loaded.configs.find { it.isDefault } + _state.value = RunConfigState.CONFIGURED + logger.info { "Loaded ${loaded.configs.size} run configs from storage" } + return + } + + // Otherwise, analyze project + analyzeProject() + } + + /** + * Analyze project to discover run configurations using LLM streaming. + */ + suspend fun analyzeProject(onProgress: (String) -> Unit = {}) { + if (_state.value == RunConfigState.ANALYZING) return + + _state.value = RunConfigState.ANALYZING + _errorMessage.value = null + + try { + // Check if LLM is available + if (llmAnalyzer == null) { + _state.value = RunConfigState.ERROR + _errorMessage.value = "LLM service not configured. Please configure an LLM provider first." + onProgress("Error: LLM service not available") + return + } + + var discoveredConfigs: List = emptyList() + + onProgress("Using AI to analyze project...") + + llmAnalyzer.analyzeStreaming().collect { event -> + when (event) { + is AnalysisEvent.Progress -> onProgress(event.message) + is AnalysisEvent.Complete -> discoveredConfigs = event.configs + is AnalysisEvent.Error -> { + logger.warn { "LLM analysis failed: ${event.message}" } + _errorMessage.value = event.message + } + } + } + + if (discoveredConfigs.isNotEmpty()) { + _configs.value = discoveredConfigs + _defaultConfig.value = discoveredConfigs.find { it.isDefault } + ?: discoveredConfigs.firstOrNull { it.type == RunConfigType.RUN } + ?: discoveredConfigs.firstOrNull() + _state.value = RunConfigState.CONFIGURED + + // Save configs + saveConfigs() + + onProgress("Found ${discoveredConfigs.size} run configurations") + logger.info { "Discovered ${discoveredConfigs.size} run configs" } + } else { + _state.value = RunConfigState.NOT_CONFIGURED + onProgress("No run configurations found. You can add custom configs manually.") + } + } catch (e: Exception) { + _state.value = RunConfigState.ERROR + _errorMessage.value = e.message ?: "Analysis failed" + logger.error { "Failed to analyze project: ${e.message}" } + } + } + + /** + * Get streaming analysis flow for UI integration. + * This allows the UI to show real-time LLM reasoning. + */ + fun analyzeStreamingFlow(): Flow? { + return llmAnalyzer?.analyzeStreaming() + } + + /** + * Execute a run configuration + */ + suspend fun execute( + configId: String, + onOutput: (String) -> Unit = {} + ): RunConfigResult { + val config = _configs.value.find { it.id == configId } + ?: return RunConfigResult( + success = false, + error = "Run config not found: $configId" + ) + + return execute(config, onOutput) + } + + /** + * Execute a run configuration with streaming output support. + * Uses live execution for long-running commands to stream output in real-time. + */ + suspend fun execute( + config: RunConfig, + onOutput: (String) -> Unit = {} + ): RunConfigResult { + if (_isRunning.value) { + return RunConfigResult( + success = false, + error = "Another command is already running" + ) + } + + _isRunning.value = true + _runningConfigId.value = config.id + + logger.info { "Executing run config: ${config.name} -> ${config.command}" } + onOutput("$ ${config.command}\n") + + return kotlinx.coroutines.coroutineScope { + executionJob = kotlinx.coroutines.currentCoroutineContext()[kotlinx.coroutines.Job] + + try { + val workDir = if (config.workingDir == ".") { + projectPath + } else { + "$projectPath/${config.workingDir}" + } + + val shellConfig = ShellExecutionConfig( + workingDirectory = workDir, + timeoutMs = 600000L, // 10 minutes for long-running tasks + environment = config.env + ) + + // Check if live execution is supported for streaming output + val liveExecutor = shellExecutor as? LiveShellExecutor + if (liveExecutor != null && liveExecutor.supportsLiveExecution()) { + // Use live streaming execution for real-time output + executeWithLiveStreaming(config.command, shellConfig, onOutput) + } else { + // Fallback to blocking execution + executeBlocking(config.command, shellConfig, onOutput) + } + } catch (e: kotlinx.coroutines.CancellationException) { + logger.info { "Command execution cancelled" } + onOutput("\n[STOPPED] Command cancelled by user\n") + RunConfigResult( + success = false, + error = "Cancelled by user" + ) + } catch (e: Exception) { + logger.error { "Failed to execute command: ${e.message}" } + onOutput("\n[ERROR] ${e.message}") + + RunConfigResult( + success = false, + error = e.message ?: "Execution failed" + ) + } finally { + _isRunning.value = false + _runningConfigId.value = null + currentSession = null + currentProcessHandle = null + executionJob = null + } + } + } + + /** + * Execute with live streaming output (for long-running commands like bootRun) + */ + private suspend fun executeWithLiveStreaming( + command: String, + config: ShellExecutionConfig, + onOutput: (String) -> Unit + ): RunConfigResult { + val liveExecutor = shellExecutor as LiveShellExecutor + val session = liveExecutor.startLiveExecution(command, config) + + // Store session for potential stop functionality + currentSession = session + + try { + // Read output from PTY process incrementally + val process = session.ptyHandle + + return if (isNativeProcess(process)) { + currentProcessHandle = process + readProcessOutputPlatform(process, onOutput, config.timeoutMs) { pid -> + // Process started callback + } + } else { + // For PTY process, use the PTY-specific handling + readPtyOutput(session, onOutput, config.timeoutMs) + } + } finally { + currentSession = null + currentProcessHandle = null + } + } + + private var currentSession: LiveShellSession? = null + private var currentProcessHandle: Any? = null + private var executionJob: kotlinx.coroutines.Job? = null + + /** + * Stop the currently running command + */ + suspend fun stopRunning() { + if (!_isRunning.value) { + logger.warn { "Attempted to stop when no command is running" } + return + } + + logger.info { "Stopping running command..." } + + // Cancel the execution job if it exists + executionJob?.cancel() + + // Kill the process using platform-specific implementation + currentProcessHandle?.let { handle -> + try { + killProcessPlatform(handle) + logger.info { "Process killed forcefully" } + } catch (e: Exception) { + logger.error { "Failed to kill process: ${e.message}" } + } + } + + // Kill the session if it exists + currentSession?.let { session -> + try { + session.kill() + logger.info { "Session killed" } + } catch (e: Exception) { + logger.error { "Failed to kill session: ${e.message}" } + } + } + + // Reset state + _isRunning.value = false + _runningConfigId.value = null + currentSession = null + currentProcessHandle = null + executionJob = null + } + + /** + * Read output from PTY session + */ + private suspend fun readPtyOutput( + session: LiveShellSession, + onOutput: (String) -> Unit, + timeoutMs: Long + ): RunConfigResult { + val liveExecutor = shellExecutor as LiveShellExecutor + + try { + val exitCode = liveExecutor.waitForSession(session, timeoutMs) + + val stdout = session.getStdout() + val stderr = session.getStderr() + + if (stdout.isNotEmpty()) onOutput(stdout) + if (stderr.isNotEmpty()) onOutput("\n[STDERR]\n$stderr") + + return RunConfigResult( + success = exitCode == 0, + exitCode = exitCode, + message = if (exitCode == 0) "Command completed" else "Command failed" + ) + } catch (e: Exception) { + // Timeout or other error - process may still be running + onOutput("\n[INFO] ${e.message}\n") + return RunConfigResult( + success = true, + message = "Process may still be running" + ) + } + } + + /** + * Fallback blocking execution + */ + private suspend fun executeBlocking( + command: String, + config: ShellExecutionConfig, + onOutput: (String) -> Unit + ): RunConfigResult { + val result = shellExecutor.execute(command, config) + + onOutput(result.stdout) + if (result.stderr.isNotBlank()) { + onOutput("\n[STDERR]\n${result.stderr}") + } + + return RunConfigResult( + success = result.exitCode == 0, + exitCode = result.exitCode, + message = if (result.exitCode == 0) { + "Command completed successfully" + } else { + "Command exited with code ${result.exitCode}" + } + ) + } + + /** + * Execute the default run configuration + */ + suspend fun executeDefault(onOutput: (String) -> Unit = {}): RunConfigResult { + val default = _defaultConfig.value + ?: return RunConfigResult( + success = false, + error = "No default run configuration" + ) + + return execute(default, onOutput) + } + + /** + * Set a config as the default + */ + fun setDefaultConfig(configId: String) { + val config = _configs.value.find { it.id == configId } + if (config != null) { + // Update default flags + _configs.value = _configs.value.map { c -> + c.copy(isDefault = c.id == configId) + } + _defaultConfig.value = config.copy(isDefault = true) + } + } + + /** + * Add a custom run configuration + */ + fun addConfig(config: RunConfig) { + _configs.value = _configs.value + config.copy(source = RunConfigSource.USER_DEFINED) + if (config.isDefault || _defaultConfig.value == null) { + _defaultConfig.value = config + } + _state.value = RunConfigState.CONFIGURED + } + + /** + * Remove a run configuration + */ + fun removeConfig(configId: String) { + _configs.value = _configs.value.filter { it.id != configId } + if (_defaultConfig.value?.id == configId) { + _defaultConfig.value = _configs.value.firstOrNull { it.isDefault } + ?: _configs.value.firstOrNull() + } + if (_configs.value.isEmpty()) { + _state.value = RunConfigState.NOT_CONFIGURED + } + } + + /** + * Get configs filtered by type + */ + fun getConfigsByType(type: RunConfigType): List { + return _configs.value.filter { it.type == type } + } + + /** + * Save configs to storage + */ + private suspend fun saveConfigs() { + try { + // Ensure .xiuper directory exists + val xiuperDir = "$projectPath/.xiuper" + if (!fileSystem.exists(xiuperDir)) { + fileSystem.createDirectory(xiuperDir) + } + + val storage = RunConfigStorage( + projectPath = projectPath, + configs = _configs.value, + defaultConfigId = _defaultConfig.value?.id, + lastAnalyzedAt = cc.unitmesh.agent.Platform.getCurrentTimestamp() + ) + + val content = json.encodeToString(RunConfigStorage.serializer(), storage) + fileSystem.writeFile(configStoragePath, content) + + logger.debug { "Saved ${_configs.value.size} run configs to $configStoragePath" } + } catch (e: Exception) { + logger.warn { "Failed to save run configs: ${e.message}" } + } + } + + /** + * Load configs from storage + */ + private suspend fun loadConfigs(): RunConfigStorage? { + return try { + if (!fileSystem.exists(configStoragePath)) { + return null + } + + val content = fileSystem.readFile(configStoragePath) ?: return null + json.decodeFromString(content) + } catch (e: Exception) { + logger.warn { "Failed to load run configs: ${e.message}" } + null + } + } + + /** + * Clear all configs and reset state + */ + fun reset() { + _configs.value = emptyList() + _defaultConfig.value = null + _state.value = RunConfigState.NOT_CONFIGURED + _errorMessage.value = null + } +} + diff --git a/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.ios.kt b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.ios.kt new file mode 100644 index 0000000000..aa5c31917b --- /dev/null +++ b/mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.ios.kt @@ -0,0 +1,23 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.datetime.Clock + +actual suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult { + return RunConfigResult( + success = false, + error = "Native process execution not supported on iOS platform" + ) +} + +actual fun killProcessPlatform(processHandle: Any?) { + // No-op on iOS +} + +actual fun isNativeProcess(handle: Any?): Boolean = false + +actual fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.js.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.js.kt new file mode 100644 index 0000000000..f1d0f19433 --- /dev/null +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.js.kt @@ -0,0 +1,23 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.datetime.Clock + +actual suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult { + return RunConfigResult( + success = false, + error = "Native process execution not supported on JS platform" + ) +} + +actual fun killProcessPlatform(processHandle: Any?) { + // No-op on JS +} + +actual fun isNativeProcess(handle: Any?): Boolean = false + +actual fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.jvm.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.jvm.kt new file mode 100644 index 0000000000..5cfc723a7b --- /dev/null +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.jvm.kt @@ -0,0 +1,111 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +actual suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult = withContext(Dispatchers.IO) { + val process = processHandle as? Process + ?: return@withContext RunConfigResult( + success = false, + error = "Invalid process handle" + ) + + val startTime = System.currentTimeMillis() + val outputBuilder = StringBuilder() + var cancelled = false + + // Read stdout in a separate thread + val stdoutReader = Thread { + try { + process.inputStream.bufferedReader().use { reader -> + val buffer = CharArray(1024) + while (!cancelled) { + val charsRead = reader.read(buffer) + if (charsRead == -1) break + val chunk = buffer.concatToString(0, charsRead) + outputBuilder.append(chunk) + onOutput(chunk) + } + } + } catch (_: Exception) { + // Stream closed, ignore + } + }.apply { start() } + + // Read stderr in a separate thread + val stderrReader = Thread { + try { + process.errorStream.bufferedReader().use { reader -> + val buffer = CharArray(1024) + while (!cancelled) { + val charsRead = reader.read(buffer) + if (charsRead == -1) break + val chunk = buffer.concatToString(0, charsRead) + outputBuilder.append(chunk) + onOutput(chunk) + } + } + } catch (_: Exception) { + // Stream closed, ignore + } + }.apply { start() } + + try { + // Wait for process with timeout, but don't kill on timeout + val completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + + // Wait for readers to finish (with short timeout) + stdoutReader.join(2000) + stderrReader.join(2000) + + if (!completed) { + // Process still running after timeout - report but don't kill + onOutput("\n[INFO] Process still running after ${timeoutMs / 1000}s timeout.\n") + onProcessStarted(process.pid().toInt()) + return@withContext RunConfigResult( + success = true, + message = "Process started (may still be running)", + pid = process.pid().toInt() + ) + } + + val exitCode = process.exitValue() + val executionTime = System.currentTimeMillis() - startTime + + RunConfigResult( + success = exitCode == 0, + exitCode = exitCode, + message = if (exitCode == 0) { + "Command completed successfully (${executionTime}ms)" + } else { + "Command exited with code $exitCode" + } + ) + } catch (e: kotlinx.coroutines.CancellationException) { + cancelled = true + stdoutReader.interrupt() + stderrReader.interrupt() + throw e + } +} + +actual fun killProcessPlatform(processHandle: Any?) { + val process = processHandle as? Process ?: return + try { + if (process.isAlive) { + process.destroyForcibly() + } + } catch (_: Exception) { + // Ignore + } +} + +actual fun isNativeProcess(handle: Any?): Boolean = handle is Process + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.wasmJs.kt b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.wasmJs.kt new file mode 100644 index 0000000000..ac8e6dfd1f --- /dev/null +++ b/mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/runconfig/ProcessOutputReader.wasmJs.kt @@ -0,0 +1,23 @@ +package cc.unitmesh.agent.runconfig + +import kotlinx.datetime.Clock + +actual suspend fun readProcessOutputPlatform( + processHandle: Any?, + onOutput: (String) -> Unit, + timeoutMs: Long, + onProcessStarted: (processId: Int?) -> Unit +): RunConfigResult { + return RunConfigResult( + success = false, + error = "Native process execution not supported on WASM platform" + ) +} + +actual fun killProcessPlatform(processHandle: Any?) { + // No-op on WASM +} + +actual fun isNativeProcess(handle: Any?): Boolean = false + +actual fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.android.kt b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.android.kt new file mode 100644 index 0000000000..d3b4259576 --- /dev/null +++ b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.android.kt @@ -0,0 +1,20 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +@Composable +actual fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) { + // Android platform: just render content without tooltip (could use BasicTooltipBox in future) + Box(modifier = modifier) { + content() + } +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt index f37eef003c..ebf7787999 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/CodingAgentPage.kt @@ -15,6 +15,9 @@ import cc.unitmesh.config.CloudStorageConfig import cc.unitmesh.config.ConfigManager import cc.unitmesh.devins.ui.base.ResizableSplitPane import cc.unitmesh.devins.ui.compose.chat.TopBarMenu +import cc.unitmesh.devins.ui.compose.runconfig.FloatingRunButton +import cc.unitmesh.devins.ui.compose.runconfig.RunConfigViewModel +import cc.unitmesh.devins.ui.compose.runconfig.RunOutputDock import cc.unitmesh.devins.ui.compose.config.CloudStorageConfigDialog import cc.unitmesh.devins.ui.compose.editor.DevInEditorInput import cc.unitmesh.devins.ui.compose.editor.multimodal.ImageUploader @@ -136,6 +139,39 @@ fun CodingAgentPage( onInternalNewChat?.invoke(handleNewChat) } + // RunConfig ViewModel for project run configurations + val runConfigViewModel = remember(currentWorkspace?.rootPath, llmService) { + val rootPath = currentWorkspace?.rootPath ?: Platform.getUserHomeDir() + RunConfigViewModel( + projectPath = rootPath, + llmService = llmService + ) + } + + val runConfigState by runConfigViewModel.state.collectAsState() + val runConfigs by runConfigViewModel.configs.collectAsState() + val defaultRunConfig by runConfigViewModel.defaultConfig.collectAsState() + val isRunning by runConfigViewModel.isRunning.collectAsState() + val runningConfigId by runConfigViewModel.runningConfigId.collectAsState() + val runOutput by runConfigViewModel.output.collectAsState() + val runAnalysisLog by runConfigViewModel.analysisLog.collectAsState() + + var showRunOutputDock by remember { mutableStateOf(false) } + + // Track which mode the dock is in (analysis or run output) + val isAnalyzing = runConfigState == cc.unitmesh.agent.runconfig.RunConfigState.ANALYZING + val dockTitle = if (isAnalyzing) "AI Analysis" else "Run Output" + val dockOutput = if (isAnalyzing) runAnalysisLog else runOutput + + // Auto-open output dock when we have output, a run is in progress, or analyzing + LaunchedEffect(isRunning, runOutput, isAnalyzing, runAnalysisLog) { + if (isRunning || runOutput.isNotBlank() || isAnalyzing || runAnalysisLog.isNotBlank()) { + showRunOutputDock = true + } + } + + // Main content + Box(modifier = modifier.fillMaxSize()) { if (isTreeViewVisibleState) { ResizableSplitPane( modifier = modifier.fillMaxSize(), @@ -224,6 +260,23 @@ fun CodingAgentPage( when (selectedAgentType) { AgentType.LOCAL_CHAT, AgentType.CODING -> { + RunOutputDock( + isVisible = showRunOutputDock, + title = dockTitle, + output = dockOutput, + isRunning = isRunning, + onClear = { + runConfigViewModel.clearOutput() + runConfigViewModel.clearAnalysisLog() + }, + onClose = { showRunOutputDock = false }, + onStop = if (!isAnalyzing) { + { runConfigViewModel.stopRunning() } + } else null, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) DevInEditorInput( initialText = "", placeholder = "Describe your coding task...", @@ -431,6 +484,23 @@ fun CodingAgentPage( ) } + RunOutputDock( + isVisible = showRunOutputDock, + title = dockTitle, + output = dockOutput, + isRunning = isRunning, + onClear = { + runConfigViewModel.clearOutput() + runConfigViewModel.clearAnalysisLog() + }, + onClose = { showRunOutputDock = false }, + onStop = if (!isAnalyzing) { + { runConfigViewModel.stopRunning() } + } else null, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) DevInEditorInput( initialText = "", placeholder = "Describe your coding task...", @@ -496,6 +566,25 @@ fun CodingAgentPage( } } + // Floating Run Button (IDE-like) - Top Right + if (useAgentMode && selectedAgentType == AgentType.CODING) { + FloatingRunButton( + state = runConfigState, + configs = runConfigs, + defaultConfig = defaultRunConfig, + isRunning = isRunning, + runningConfigId = runningConfigId, + analysisLog = runAnalysisLog, + onConfigure = { runConfigViewModel.analyzeProject() }, + onRunConfig = { cfg -> runConfigViewModel.runConfig(cfg) }, + onStopRunning = { runConfigViewModel.stopRunning() }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 16.dp, top = 16.dp) + ) + } + } // End of main Box + // Cloud Storage Configuration Dialog if (showCloudStorageDialog) { CloudStorageConfigDialog( diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenu.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenu.kt index 52c5b93efe..007c1aaf2b 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenu.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/TopBarMenu.kt @@ -64,6 +64,7 @@ fun TopBarMenu( modifier = modifier ) } else if (Platform.isWasm) { + // Only WASM uses TopBarMenuDesktop - Desktop JVM has its own navigation TopBarMenuDesktop( hasHistory = hasHistory, hasDebugInfo = hasDebugInfo, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.kt new file mode 100644 index 0000000000..558e78ef4b --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.kt @@ -0,0 +1,19 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +/** + * Cross-platform tooltip wrapper. + * On Desktop: Uses TooltipArea with full tooltip support + * On other platforms: Just renders the content without tooltip + */ +@Composable +expect fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt index 5e3a6710e8..0d572f59de 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt @@ -114,6 +114,10 @@ object AutoDevComposeIcons { val Functions: ImageVector get() = Icons.Default.Functions val KeyboardArrowRight: ImageVector get() = Icons.Default.KeyboardArrowRight + // GenAction Icons + val Science: ImageVector get() = Icons.Default.Science + val Extension: ImageVector get() = Icons.Default.Extension + // Database Icons val Database: ImageVector get() = Icons.Default.Storage val Schema: ImageVector get() = Icons.Default.TableChart diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/FloatingRunButton.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/FloatingRunButton.kt new file mode 100644 index 0000000000..111012ace0 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/FloatingRunButton.kt @@ -0,0 +1,212 @@ +package cc.unitmesh.devins.ui.compose.runconfig + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.runconfig.RunConfig +import cc.unitmesh.agent.runconfig.RunConfigState +import cc.unitmesh.devins.ui.compose.components.TooltipWrapper +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors + +/** + * FloatingRunButton - IDE-like floating Run entry. + * + * Behavior: + * - NOT_CONFIGURED: shows a "Settings" FAB for configure + * - ANALYZING: shows spinning refresh with AI log tooltip on hover + * - CONFIGURED: shows Run/Stop FAB; tap opens menu with actions + * - ERROR: shows "Config Error" FAB + */ +@Composable +fun FloatingRunButton( + state: RunConfigState, + configs: List, + defaultConfig: RunConfig?, + isRunning: Boolean, + runningConfigId: String?, + analysisLog: String = "", + onConfigure: () -> Unit, + onRunConfig: (RunConfig) -> Unit, + onStopRunning: () -> Unit, + modifier: Modifier = Modifier +) { + var menuExpanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + when (state) { + RunConfigState.NOT_CONFIGURED -> { + SmallFloatingActionButton( + onClick = onConfigure, + containerColor = MaterialTheme.colorScheme.primaryContainer + ) { + Icon( + imageVector = AutoDevComposeIcons.Settings, + contentDescription = "Configure Run", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + RunConfigState.ANALYZING -> { + // Spinning animation + val infiniteTransition = rememberInfiniteTransition(label = "spin") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing) + ), + label = "rotation" + ) + + // Tooltip showing AI analysis log + TooltipWrapper( + tooltip = { + if (analysisLog.isNotBlank()) { + Box( + modifier = Modifier + .widthIn(max = 320.dp) + .heightIn(max = 200.dp) + .background( + color = MaterialTheme.colorScheme.inverseSurface, + shape = RoundedCornerShape(8.dp) + ) + .padding(12.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = analysisLog, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.inverseOnSurface + ) + } + } + }, + tooltipOffset = DpOffset(0.dp, 16.dp), + delayMillis = 300, + modifier = Modifier + ) { + SmallFloatingActionButton( + onClick = {}, + containerColor = AutoDevColors.Signal.info.copy(alpha = 0.15f) + ) { + Icon( + imageVector = AutoDevComposeIcons.Sync, + contentDescription = "Analyzing with AI...", + tint = AutoDevColors.Signal.info, + modifier = Modifier.rotate(rotation) + ) + } + } + } + + RunConfigState.ERROR -> { + SmallFloatingActionButton( + onClick = onConfigure, + containerColor = AutoDevColors.Signal.error.copy(alpha = 0.12f) + ) { + Icon( + imageVector = AutoDevComposeIcons.Warning, + contentDescription = "Run config error", + tint = AutoDevColors.Signal.error + ) + } + } + + RunConfigState.CONFIGURED -> { + val isDefaultRunning = isRunning && runningConfigId != null && runningConfigId == defaultConfig?.id + val fabTint = if (isDefaultRunning) AutoDevColors.Signal.error else AutoDevColors.Signal.success + + SmallFloatingActionButton( + onClick = { menuExpanded = true }, + containerColor = fabTint.copy(alpha = 0.12f) + ) { + Icon( + imageVector = if (isDefaultRunning) AutoDevComposeIcons.Stop else AutoDevComposeIcons.PlayArrow, + contentDescription = if (isDefaultRunning) "Stop" else "Run", + tint = fabTint + ) + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + // Show all configs (avoid duplicates) + configs.forEach { cfg -> + val running = isRunning && runningConfigId == cfg.id + val isDefault = cfg.id == defaultConfig?.id + val tint = when { + running -> AutoDevColors.Signal.error + isDefault -> AutoDevColors.Signal.success + else -> MaterialTheme.colorScheme.onSurface + } + DropdownMenuItem( + text = { + Text( + text = if (isDefault) "${cfg.name} (default)" else cfg.name, + style = if (isDefault) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall + ) + }, + onClick = { + menuExpanded = false + println("[FloatingRunButton] Running config: ${cfg.name}") + if (running) onStopRunning() else onRunConfig(cfg) + }, + leadingIcon = { + Icon( + imageVector = if (running) AutoDevComposeIcons.Stop else AutoDevComposeIcons.PlayArrow, + contentDescription = null, + tint = tint + ) + } + ) + } + + DropdownMenuItem( + text = { Text("Re-analyze Project") }, + onClick = { + menuExpanded = false + onConfigure() + }, + leadingIcon = { + Icon( + imageVector = AutoDevComposeIcons.Refresh, + contentDescription = null + ) + } + ) + } + } + } + } +} + + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunButton.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunButton.kt new file mode 100644 index 0000000000..3fca23c743 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunButton.kt @@ -0,0 +1,409 @@ +package cc.unitmesh.devins.ui.compose.runconfig + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.runconfig.RunConfig +import cc.unitmesh.agent.runconfig.RunConfigState +import cc.unitmesh.agent.runconfig.RunConfigType +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors + +/** + * RunButton - A dropdown button for project run configurations. + * + * Displays in the top-right area of the TopBar: + * - "Configure" button if no run configs exist + * - Green play button with dropdown if configs available + * + * Similar to IDE run buttons (IntelliJ, VS Code). + */ +@Composable +fun RunButton( + state: RunConfigState, + configs: List, + defaultConfig: RunConfig?, + isRunning: Boolean, + runningConfigId: String?, + onConfigure: () -> Unit, + onRunConfig: (RunConfig) -> Unit, + onStopRunning: () -> Unit = {}, + modifier: Modifier = Modifier +) { + var menuExpanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + when (state) { + RunConfigState.NOT_CONFIGURED -> { + // Configure button + ConfigureButton(onClick = onConfigure) + } + + RunConfigState.ANALYZING -> { + // Loading state + AnalyzingButton() + } + + RunConfigState.CONFIGURED -> { + // Run button with dropdown + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Main run button + RunPlayButton( + config = defaultConfig, + isRunning = isRunning && runningConfigId == defaultConfig?.id, + onClick = { + if (isRunning && runningConfigId == defaultConfig?.id) { + onStopRunning() + } else { + defaultConfig?.let { onRunConfig(it) } + } + } + ) + + // Dropdown arrow + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.ArrowDropDown, + contentDescription = "More run options", + tint = AutoDevColors.Signal.success, + modifier = Modifier.size(18.dp) + ) + } + } + + // Dropdown menu + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + // Group by type + val grouped = configs.groupBy { it.type } + + grouped.forEach { (type, typeConfigs) -> + // Type header + Text( + text = type.name.replace("_", " "), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + typeConfigs.forEach { config -> + RunConfigMenuItem( + config = config, + isRunning = isRunning && runningConfigId == config.id, + isDefault = config.id == defaultConfig?.id, + onClick = { + menuExpanded = false + if (isRunning && runningConfigId == config.id) { + onStopRunning() + } else { + onRunConfig(config) + } + } + ) + } + + if (type != grouped.keys.last()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Re-analyze option + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text("Re-analyze Project") + } + }, + onClick = { + menuExpanded = false + onConfigure() + } + ) + } + } + + RunConfigState.ERROR -> { + // Error state - show configure button with error indicator + ConfigureButton( + onClick = onConfigure, + hasError = true + ) + } + } + } +} + +/** + * Configure button when no run configs exist + */ +@Composable +private fun ConfigureButton( + onClick: () -> Unit, + hasError: Boolean = false +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(6.dp), + color = if (hasError) { + AutoDevColors.Signal.error.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = Modifier.height(28.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = if (hasError) AutoDevComposeIcons.Warning else AutoDevComposeIcons.PlayArrow, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (hasError) { + AutoDevColors.Signal.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Text( + text = if (hasError) "Config Error" else "Configure", + style = MaterialTheme.typography.labelMedium, + color = if (hasError) { + AutoDevColors.Signal.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } +} + +/** + * Analyzing/loading button + */ +@Composable +private fun AnalyzingButton() { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.height(28.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp).rotate(rotation), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "Analyzing...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Main run/play button + */ +@Composable +private fun RunPlayButton( + config: RunConfig?, + isRunning: Boolean, + onClick: () -> Unit +) { + val buttonColor = if (isRunning) { + AutoDevColors.Signal.error + } else { + AutoDevColors.Signal.success + } + + Surface( + onClick = onClick, + shape = RoundedCornerShape(6.dp), + color = buttonColor.copy(alpha = 0.15f), + modifier = Modifier.height(28.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = if (isRunning) AutoDevComposeIcons.Stop else AutoDevComposeIcons.PlayArrow, + contentDescription = if (isRunning) "Stop" else "Run", + modifier = Modifier.size(16.dp), + tint = buttonColor + ) + Text( + text = if (isRunning) "Stop" else (config?.name ?: "Run"), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium + ), + color = buttonColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 100.dp) + ) + } + } +} + +/** + * Menu item for a run configuration + */ +@Composable +private fun RunConfigMenuItem( + config: RunConfig, + isRunning: Boolean, + isDefault: Boolean, + onClick: () -> Unit +) { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + // Type icon + Icon( + imageVector = getTypeIcon(config.type), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isRunning) { + AutoDevColors.Signal.error + } else { + getTypeColor(config.type) + } + ) + + // Config name + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = config.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isDefault) FontWeight.Medium else FontWeight.Normal + ) + if (isDefault) { + Text( + text = "(default)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + if (config.description.isNotBlank()) { + Text( + text = config.description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Running indicator + if (isRunning) { + Icon( + imageVector = AutoDevComposeIcons.Stop, + contentDescription = "Running", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Signal.error + ) + } + } + }, + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) +} + +/** + * Get icon for run config type + */ +@Composable +private fun getTypeIcon(type: RunConfigType): androidx.compose.ui.graphics.vector.ImageVector { + return when (type) { + RunConfigType.RUN -> AutoDevComposeIcons.PlayArrow + RunConfigType.DEV -> AutoDevComposeIcons.Code + RunConfigType.TEST -> AutoDevComposeIcons.Science + RunConfigType.BUILD -> AutoDevComposeIcons.Build + RunConfigType.LINT -> AutoDevComposeIcons.Check + RunConfigType.DEPLOY -> AutoDevComposeIcons.RocketLaunch + RunConfigType.CLEAN -> AutoDevComposeIcons.Delete + RunConfigType.INSTALL -> AutoDevComposeIcons.CloudDownload + RunConfigType.CUSTOM -> AutoDevComposeIcons.Terminal + } +} + +/** + * Get color for run config type + */ +@Composable +private fun getTypeColor(type: RunConfigType): Color { + return when (type) { + RunConfigType.RUN -> AutoDevColors.Signal.success + RunConfigType.DEV -> AutoDevColors.Energy.xiu + RunConfigType.TEST -> AutoDevColors.Signal.info + RunConfigType.BUILD -> AutoDevColors.Signal.warn + RunConfigType.LINT -> AutoDevColors.Energy.ai + RunConfigType.DEPLOY -> AutoDevColors.Energy.ai + RunConfigType.CLEAN -> MaterialTheme.colorScheme.outline + RunConfigType.INSTALL -> AutoDevColors.Signal.info + RunConfigType.CUSTOM -> MaterialTheme.colorScheme.onSurface + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunConfigViewModel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunConfigViewModel.kt new file mode 100644 index 0000000000..c5d7e045e8 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunConfigViewModel.kt @@ -0,0 +1,210 @@ +package cc.unitmesh.devins.ui.compose.runconfig + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.runconfig.* +import cc.unitmesh.agent.tool.filesystem.DefaultToolFileSystem +import cc.unitmesh.agent.tool.shell.DefaultShellExecutor +import cc.unitmesh.llm.KoogLLMService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * ViewModel for managing run configurations in the UI. + * + * This ViewModel: + * 1. Initializes RunConfigService with the project path + * 2. Uses LLM streaming for intelligent project analysis + * 3. Exposes state flows for the UI + * 4. Handles user interactions (analyze, run, stop) + */ +class RunConfigViewModel( + projectPath: String, + llmService: KoogLLMService? = null +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val service = RunConfigService( + projectPath = projectPath, + fileSystem = DefaultToolFileSystem(projectPath = projectPath), + shellExecutor = DefaultShellExecutor(), + llmService = llmService + ) + + // State exposed to UI + val state: StateFlow = service.state + val configs: StateFlow> = service.configs + val defaultConfig: StateFlow = service.defaultConfig + val isRunning: StateFlow = service.isRunning + val runningConfigId: StateFlow = service.runningConfigId + + // Output from running commands + private val _output = MutableStateFlow("") + val output: StateFlow = _output.asStateFlow() + + // Streaming analysis log (shows LLM reasoning) + private val _analysisLog = MutableStateFlow("") + val analysisLog: StateFlow = _analysisLog.asStateFlow() + + // Progress message during analysis + var progressMessage by mutableStateOf(null) + private set + + // Error message + var errorMessage by mutableStateOf(null) + private set + + init { + // Initialize on creation + scope.launch { + service.initialize() + } + } + + /** + * Analyze project to discover run configurations using LLM streaming. + */ + fun analyzeProject() { + scope.launch { + errorMessage = null + _analysisLog.value = "๐Ÿ” Starting project analysis...\n\n" + progressMessage = "Analyzing with AI..." + println("[RunConfigViewModel] Starting project analysis...") + + service.analyzeProject { progress -> + progressMessage = progress.take(50) + // Append progress directly (streaming style) + _analysisLog.value = _analysisLog.value + progress + println("[RunConfigViewModel] Progress: $progress") + } + + // Check for errors from service + service.errorMessage.value?.let { error -> + errorMessage = error + _analysisLog.value = _analysisLog.value + "\n\nโŒ Error: $error\n" + } + + progressMessage = null + val configCount = configs.value.size + if (configCount > 0) { + // Show discovered configs summary + val configSummary = configs.value.take(5).joinToString("\n") { " โ€ข ${it.name}: ${it.command}" } + _analysisLog.value = _analysisLog.value + "\n\n๐Ÿ“‹ Discovered configurations:\n$configSummary" + if (configCount > 5) { + _analysisLog.value = _analysisLog.value + "\n ... and ${configCount - 5} more" + } + } + println("[RunConfigViewModel] Analysis complete. Found $configCount configs, default=${defaultConfig.value?.name}") + } + } + + /** + * Clear the analysis log + */ + fun clearAnalysisLog() { + _analysisLog.value = "" + } + + /** + * Run a configuration + */ + fun runConfig(config: RunConfig) { + scope.launch { + _output.value = "Starting: ${config.command}\n" + errorMessage = null + val result = service.execute(config) { outputLine -> + _output.value = _output.value + outputLine + } + + if (!result.success) { + errorMessage = result.error ?: "Command failed" + _output.value = _output.value + "\n\n[FAILED] ${result.error ?: "Command failed"}\n" + } else { + _output.value = _output.value + "\n\n[DONE] Exit code: ${result.exitCode ?: 0}\n" + } + } + } + + /** + * Run the default configuration + */ + fun runDefault() { + defaultConfig.value?.let { runConfig(it) } + } + + /** + * Stop the currently running command + */ + fun stopRunning() { + scope.launch { + service.stopRunning() + _output.value = _output.value + "\n[STOPPED] Command cancelled by user\n" + } + } + + /** + * Set a config as the default + */ + fun setDefaultConfig(configId: String) { + service.setDefaultConfig(configId) + } + + /** + * Add a custom configuration + */ + fun addCustomConfig( + name: String, + command: String, + type: RunConfigType = RunConfigType.CUSTOM, + description: String = "" + ) { + val config = RunConfig( + id = "custom-${Platform.getCurrentTimestamp()}", + name = name, + type = type, + command = command, + description = description, + source = RunConfigSource.USER_DEFINED + ) + service.addConfig(config) + } + + /** + * Remove a configuration + */ + fun removeConfig(configId: String) { + service.removeConfig(configId) + } + + /** + * Clear output + */ + fun clearOutput() { + _output.value = "" + } + + /** + * Clear error + */ + fun clearError() { + errorMessage = null + } + + /** + * Reset and re-analyze + */ + fun reset() { + service.reset() + _output.value = "" + errorMessage = null + progressMessage = null + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunOutputDock.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunOutputDock.kt new file mode 100644 index 0000000000..e5f98617c0 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/runconfig/RunOutputDock.kt @@ -0,0 +1,143 @@ +package cc.unitmesh.devins.ui.compose.runconfig + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons +import cc.unitmesh.devins.ui.compose.terminal.PlatformTerminalDisplay + +/** + * RunOutputDock - a lightweight terminal-like dock for showing RunConfig output. + * This is intentionally simple and cross-platform: + * - JVM: renders via JediTerm (PlatformTerminalDisplay actual) + * - Other: ANSI text renderer fallback + * + * Used for both: + * - AI Analysis output (shows LLM reasoning and discovered configs) + * - Run command output (shows command execution logs) + */ +@Composable +fun RunOutputDock( + isVisible: Boolean, + title: String = "Run Output", + output: String, + isRunning: Boolean = false, + onClear: () -> Unit, + onClose: () -> Unit, + onStop: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val isAnalysis = title.contains("Analysis", ignoreCase = true) + val headerColor = if (isAnalysis) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val headerIcon = if (isAnalysis) { + AutoDevComposeIcons.SmartToy + } else { + AutoDevComposeIcons.Terminal + } + + AnimatedVisibility(visible = isVisible) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(headerColor.copy(alpha = 0.3f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = headerIcon, + contentDescription = null, + tint = if (isAnalysis) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + // Show Stop button when running (not during analysis) + if (isRunning && !isAnalysis && onStop != null) { + IconButton( + onClick = onStop, + modifier = Modifier.padding(end = 4.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Stop, + contentDescription = "Stop", + tint = MaterialTheme.colorScheme.error + ) + } + } + IconButton(onClick = onClear) { + Icon( + imageVector = AutoDevComposeIcons.Delete, + contentDescription = "Clear output" + ) + } + IconButton(onClick = onClose) { + Icon( + imageVector = AutoDevComposeIcons.Close, + contentDescription = "Close" + ) + } + } + } + HorizontalDivider() + // Dynamic height based on content type + val dockHeight = if (isAnalysis) 220.dp else 180.dp + + if (output.isBlank()) { + Text( + text = if (isAnalysis) "AI is analyzing..." else "Waiting for output...", + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + PlatformTerminalDisplay( + output = output, + modifier = Modifier + .fillMaxWidth() + .height(dockHeight) + .padding(8.dp) + ) + } + } + } + } +} + + diff --git a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.ios.kt b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.ios.kt new file mode 100644 index 0000000000..1b23da80f0 --- /dev/null +++ b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.ios.kt @@ -0,0 +1,20 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +@Composable +actual fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) { + // iOS platform: just render content without tooltip + Box(modifier = modifier) { + content() + } +} diff --git a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.js.kt b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.js.kt new file mode 100644 index 0000000000..4afa4c35cb --- /dev/null +++ b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.js.kt @@ -0,0 +1,20 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +@Composable +actual fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) { + // JS platform: just render content without tooltip + Box(modifier = modifier) { + content() + } +} diff --git a/mpp-ui/src/jsMain/typescript/processors/SlashCommandProcessor.ts b/mpp-ui/src/jsMain/typescript/processors/SlashCommandProcessor.ts index f220dcdcc5..80c75da6bf 100644 --- a/mpp-ui/src/jsMain/typescript/processors/SlashCommandProcessor.ts +++ b/mpp-ui/src/jsMain/typescript/processors/SlashCommandProcessor.ts @@ -111,6 +111,13 @@ export class SlashCommandProcessor implements InputProcessor { aliases: ['dr', 'research'], action: async (context, args) => this.handleDeepResearchCommand(context, args) }); + + // /gen-actions - Generate IDE actions from AGENTS.md rules + this.registerCommand('gen-actions', { + description: 'Generate IDE actions based on AGENTS.md rules and project context', + aliases: ['ga', 'actions'], + action: async (context, args) => this.handleGenActionsCommand(context, args) + }); } /** @@ -484,4 +491,154 @@ export class SlashCommandProcessor implements InputProcessor { }; } } + + /** + * Handle /gen-actions command for generating IDE actions from AGENTS.md + * + * This command: + * 1. Loads AGENTS.md rules from project + * 2. Analyzes current context (file, selection, etc.) + * 3. Generates contextually relevant IDE actions using LLM + * 4. Displays generated actions in a structured format + */ + private async handleGenActionsCommand(context: ProcessorContext, args: string): Promise { + try { + const projectPath = getCurrentProjectPath(); + if (!projectPath) { + return { + type: 'error', + message: 'โŒ Unable to get project path' + }; + } + + // Parse arguments + const parts = args.trim().split(/\s+/); + let focusFile: string | undefined; + let category: string | undefined; + + // Check for flags + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '--file' && parts[i + 1]) { + focusFile = parts[i + 1]; + i++; + } else if (parts[i] === '--category' && parts[i + 1]) { + category = parts[i + 1]; + i++; + } + } + + // Load configuration + const config = await ConfigManager.load(); + const activeConfig = config.getActiveConfig(); + if (!activeConfig) { + return { + type: 'error', + message: 'โŒ No LLM configuration found. Please run the setup first.' + }; + } + + // Display banner + console.log('\n' + '='.repeat(60)); + console.log('๐Ÿš€ GenAction - Dynamic IDE Action Generation'); + console.log('='.repeat(60)); + console.log(`๐Ÿ“ Project: ${projectPath}`); + if (focusFile) { + console.log(`๐Ÿ“„ Focus File: ${focusFile}`); + } + if (category) { + console.log(`๐Ÿท๏ธ Category: ${category}`); + } + console.log('='.repeat(60) + '\n'); + + // Create LLM service + const modelConfig = new mppCore.cc.unitmesh.llm.JsModelConfig( + activeConfig.provider, + activeConfig.model, + activeConfig.apiKey, + activeConfig.temperature || 0.7, + activeConfig.maxTokens || 4096, + activeConfig.baseUrl || '' + ); + + const llmService = mppCore.cc.unitmesh.llm.JsKoogLLMService.Companion.create(modelConfig); + + // Create file system + const fileSystem = mppCore.cc.unitmesh.devins.filesystem.JsFileSystemFactory.Companion.createFileSystem(projectPath); + + // Create GenActionService + const genActionService = new mppCore.cc.unitmesh.agent.genaction.JsGenActionService( + projectPath, + llmService, + fileSystem + ); + + // Generate actions + console.log('๐Ÿ”„ Generating context-aware actions...\n'); + + const actions = await genActionService.generateActions( + focusFile, + (progress: string) => { + console.log(` ${progress}`); + } + ); + + // Display results + if (actions && actions.length > 0) { + console.log('\n' + 'โ”€'.repeat(60)); + console.log('๐Ÿ“‹ Generated Actions:'); + console.log('โ”€'.repeat(60) + '\n'); + + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + const icon = getCategoryIcon(action.category); + console.log(`${i + 1}. ${icon} ${action.name}`); + console.log(` ${action.description}`); + if (action.tags && action.tags.length > 0) { + console.log(` Tags: ${action.tags.join(', ')}`); + } + console.log(''); + } + + console.log('โ”€'.repeat(60)); + console.log(`โœ… Generated ${actions.length} actions`); + console.log('๐Ÿ’ก Use the GenAction panel (floating button) to execute actions'); + console.log('โ”€'.repeat(60) + '\n'); + + return { + type: 'handled', + output: `Generated ${actions.length} IDE actions. Click the floating โœจ button to access them.` + }; + } else { + return { + type: 'handled', + output: 'โš ๏ธ No actions generated. Make sure you have an AGENTS.md file in your project.' + }; + } + + } catch (error) { + context.logger.error('[SlashCommandProcessor] Error in /gen-actions command:', error); + return { + type: 'error', + message: `โŒ GenAction failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } +} + +/** + * Get icon for action category + */ +function getCategoryIcon(category: string | undefined): string { + switch (category?.toUpperCase()) { + case 'CODE_GENERATION': return 'โœจ'; + case 'REFACTORING': return '๐Ÿ”ง'; + case 'TESTING': return '๐Ÿงช'; + case 'DOCUMENTATION': return '๐Ÿ“'; + case 'ANALYSIS': return '๐Ÿ”'; + case 'DEBUGGING': return '๐Ÿ›'; + case 'BUILD': return '๐Ÿ—๏ธ'; + case 'VCS': return '๐Ÿ“š'; + case 'CUSTOM': return '๐ŸŽฏ'; + default: return 'โšก'; + } } diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.jvm.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.jvm.kt new file mode 100644 index 0000000000..abf9deb9ae --- /dev/null +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.jvm.kt @@ -0,0 +1,26 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +@OptIn(ExperimentalFoundationApi::class) +@Composable +actual fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) { + TooltipArea( + tooltip = tooltip, + tooltipPlacement = TooltipPlacement.CursorPoint(offset = tooltipOffset), + delayMillis = delayMillis, + modifier = modifier, + content = content + ) +} diff --git a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.wasmJs.kt b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.wasmJs.kt new file mode 100644 index 0000000000..267f1b29e8 --- /dev/null +++ b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/components/TooltipWrapper.wasmJs.kt @@ -0,0 +1,20 @@ +package cc.unitmesh.devins.ui.compose.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset + +@Composable +actual fun TooltipWrapper( + tooltip: @Composable () -> Unit, + tooltipOffset: DpOffset, + delayMillis: Int, + modifier: Modifier, + content: @Composable () -> Unit +) { + // WASM platform: just render content without tooltip + Box(modifier = modifier) { + content() + } +}