Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ class CodingAgent(
private val policyEngine = DefaultPolicyEngine()
private val toolOrchestrator = ToolOrchestrator(toolRegistry, policyEngine, renderer, mcpConfigService = mcpToolConfigService)

/**
* Get the PlanStateService for observing plan state changes.
* Returns null if no plan tool is registered.
*/
fun getPlanStateService(): cc.unitmesh.agent.plan.PlanStateService? {
return toolOrchestrator.getPlanStateService()
}

private val errorRecoveryAgent = ErrorRecoveryAgent(projectPath, llmService)
private val analysisAgent = AnalysisAgent(llmService, contentThreshold = 15000)
private val mcpToolsInitializer = McpToolsInitializer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,31 @@ class PlanStateService {
val plan = _currentPlan.value ?: return
val task = plan.getTask(taskId) ?: return
task.updateStatus(status)
// Trigger StateFlow update by reassigning the plan object
_currentPlan.value = plan
notifyTaskUpdated(task)
}

/**
* Complete a step within a task.
*/
fun completeStep(taskId: String, stepId: String) {
val plan = _currentPlan.value ?: return
plan.completeStep(taskId, stepId)
// Trigger StateFlow update by reassigning the plan object
_currentPlan.value = plan
notifyStepCompleted(taskId, stepId)
}

/**
* Update a step's status.
*/
fun updateStepStatus(taskId: String, stepId: String, status: TaskStatus) {
val plan = _currentPlan.value ?: return
val task = plan.getTask(taskId) ?: return
task.updateStepStatus(stepId, status)
// Trigger StateFlow update by reassigning the plan object
_currentPlan.value = plan
notifyTaskUpdated(task)
}

Expand Down
29 changes: 29 additions & 0 deletions mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/CodingAgentExports.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cc.unitmesh.agent.config.JsToolConfigFile
import cc.unitmesh.agent.render.DefaultCodingAgentRenderer
import cc.unitmesh.llm.JsMessage
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.promise
import kotlin.js.Promise

Expand Down Expand Up @@ -182,6 +183,34 @@ class JsCodingAgent(
JsMessage(msg.role.name.lowercase(), msg.content)
}.toTypedArray()
}

/**
* Observe plan state changes and call the callback with plan summary data.
* Returns a function to stop observing.
*/
@JsName("observePlanState")
fun observePlanState(callback: (JsPlanSummaryData?) -> Unit): () -> Unit {
val planStateService = agent.getPlanStateService()
if (planStateService == null) {
return { }
}

var job: kotlinx.coroutines.Job? = null
job = GlobalScope.launch {
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using GlobalScope.launch is generally discouraged as it creates a coroutine that lives for the entire application lifetime and can lead to memory leaks if not properly managed. While the returned cancellation function helps, a better approach would be to use a structured concurrency scope tied to the agent's lifecycle. Consider using a scope that can be properly cancelled when the agent is disposed, or document why GlobalScope is necessary here.

Copilot uses AI. Check for mistakes.
planStateService.currentPlan.collect { plan ->
if (plan != null) {
val summary = cc.unitmesh.agent.plan.PlanSummaryData.from(plan)
callback(JsPlanSummaryData.from(summary))
} else {
callback(null)
}
}
}

return {
job.cancel()
}
}
}


54 changes: 52 additions & 2 deletions mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,57 @@
package cc.unitmesh.agent

import cc.unitmesh.agent.plan.PlanSummaryData
import cc.unitmesh.agent.plan.StepSummary
import cc.unitmesh.agent.plan.TaskSummary
import cc.unitmesh.agent.render.CodingAgentRenderer
import kotlin.js.JsExport

/**
* JS-friendly step summary data
*/
@JsExport
data class JsStepSummary(
val id: String,
val description: String,
val status: String
) {
companion object {
fun from(step: StepSummary): JsStepSummary {
return JsStepSummary(
id = step.id,
description = step.description,
status = step.status.name
)
}
}
}

/**
* JS-friendly task summary data
*/
@JsExport
data class JsTaskSummary(
val id: String,
val title: String,
val status: String,
val completedSteps: Int,
val totalSteps: Int,
val steps: Array<JsStepSummary>
) {
companion object {
fun from(task: TaskSummary): JsTaskSummary {
return JsTaskSummary(
id = task.id,
title = task.title,
status = task.status.name,
completedSteps = task.completedSteps,
totalSteps = task.totalSteps,
steps = task.steps.map { JsStepSummary.from(it) }.toTypedArray()
)
}
}
}

/**
* JS-friendly plan summary data
*/
Expand All @@ -16,7 +64,8 @@ data class JsPlanSummaryData(
val failedSteps: Int,
val progressPercent: Int,
val status: String,
val currentStepDescription: String?
val currentStepDescription: String?,
val tasks: Array<JsTaskSummary>
) {
companion object {
fun from(summary: PlanSummaryData): JsPlanSummaryData {
Expand All @@ -28,7 +77,8 @@ data class JsPlanSummaryData(
failedSteps = summary.failedSteps,
progressPercent = summary.progressPercent,
status = summary.status.name,
currentStepDescription = summary.currentStepDescription
currentStepDescription = summary.currentStepDescription,
tasks = summary.tasks.map { JsTaskSummary.from(it) }.toTypedArray()
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import cc.unitmesh.agent.render.ToolCallInfo
import cc.unitmesh.agent.tool.ToolType
import cc.unitmesh.agent.tool.toToolType
import cc.unitmesh.llm.compression.TokenInfo
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

private val jewelRendererLogger = Logger.getInstance("JewelRenderer")

/**
* Jewel-compatible Renderer for IntelliJ IDEA plugin.
*
Expand Down Expand Up @@ -84,6 +87,15 @@ class JewelRenderer : BaseRenderer() {
private val _currentPlan = MutableStateFlow<AgentPlan?>(null)
val currentPlan: StateFlow<AgentPlan?> = _currentPlan.asStateFlow()

/**
* Set the current plan directly.
* Used to sync with PlanStateService from CodingAgent.
*/
fun setPlan(plan: AgentPlan?) {
jewelRendererLogger.info("setPlan: plan=${plan != null}, tasks=${plan?.tasks?.size ?: 0}")
_currentPlan.value = plan
}

// BaseRenderer implementation

override fun renderIterationHeader(current: Int, max: Int) {
Expand Down Expand Up @@ -137,17 +149,22 @@ class JewelRenderer : BaseRenderer() {
}

override fun renderToolCall(toolName: String, paramsStr: String) {
jewelRendererLogger.info("renderToolCall: toolName=$toolName, paramsStr length=${paramsStr.length}")

val toolInfo = formatToolCallDisplay(toolName, paramsStr)
val params = parseParamsString(paramsStr)
val toolType = toolName.toToolType()

jewelRendererLogger.info("renderToolCall: parsed params keys=${params.keys}")

// Handle task-boundary tool - update task list
if (toolName == "task-boundary") {
updateTaskFromToolCall(params)
}

// Handle plan management tool - update plan state
if (toolName == "plan") {
jewelRendererLogger.info("renderToolCall: detected plan tool, calling updatePlanFromToolCall")
updatePlanFromToolCall(params)
// Skip rendering plan tool to timeline - it's shown in PlanSummaryBar
return
Expand Down Expand Up @@ -302,10 +319,18 @@ class JewelRenderer : BaseRenderer() {
* Internal method to update plan state
*/
private fun updatePlanState(action: String, planMarkdown: String, taskIndex: Int?, stepIndex: Int?) {
jewelRendererLogger.info("updatePlanState: action=$action, planMarkdown length=${planMarkdown.length}")
when (action) {
"CREATE", "UPDATE" -> {
if (planMarkdown.isNotBlank()) {
_currentPlan.value = MarkdownPlanParser.parseToPlan(planMarkdown)
try {
val plan = MarkdownPlanParser.parseToPlan(planMarkdown)
jewelRendererLogger.info("Parsed plan: ${plan.tasks.size} tasks")
_currentPlan.value = plan
} catch (e: Exception) {
jewelRendererLogger.warn("Failed to parse plan markdown", e)
// Keep previous valid plan on parse failure
}
}
}
"COMPLETE_STEP" -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ fun IdeaTerminalRenderer(

// Command display
CommandDisplay(command = command, isDangerous = isDangerous, dangerReason = dangerReason)

// Output (if available)
if (showOutput && executionResult != null) {
OutputDisplay(result = executionResult!!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,32 @@ class IdeaAgentViewModel(
enableLLMStreaming = true
)
agentInitialized = true

// Start observing PlanStateService and sync to renderer
startPlanStateObserver(codingAgent!!)
}
return codingAgent!!
}

// Job for observing PlanStateService
private var planStateObserverJob: Job? = null

/**
* Start observing PlanStateService and sync plan state to renderer.
*/
private fun startPlanStateObserver(agent: CodingAgent) {
// Cancel any existing observer
planStateObserverJob?.cancel()

val planStateService = agent.getPlanStateService() ?: return

planStateObserverJob = coroutineScope.launch {
planStateService.currentPlan.collect { plan ->
renderer.setPlan(plan)
}
}
}

/**
* Check if LLM service is configured.
*/
Expand Down Expand Up @@ -554,6 +576,7 @@ class IdeaAgentViewModel(

override fun dispose() {
currentJob?.cancel()
planStateObserverJob?.cancel()
coroutineScope.cancel()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import cc.unitmesh.agent.plan.AgentPlan
import cc.unitmesh.devins.idea.editor.*
import cc.unitmesh.devins.idea.toolwindow.plan.IdeaPlanSummaryBar
import cc.unitmesh.devins.idea.toolwindow.changes.IdeaFileChangeSummary
import cc.unitmesh.llm.NamedModelConfig
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
Expand Down Expand Up @@ -111,6 +112,12 @@ fun IdeaDevInInputArea(
modifier = Modifier.fillMaxWidth()
)

// File change summary - shown when there are file changes
IdeaFileChangeSummary(
project = project,
modifier = Modifier.fillMaxWidth()
)

// Top toolbar with file selection (no individual border)
IdeaTopToolbar(
project = project,
Expand Down
Loading
Loading