Skip to content

Commit 92a5b3c

Browse files
committed
feat(terminal): add completion status to LiveTerminalItem
Show exit code, execution time, and output for live terminal sessions after completion. Prevent summarization or replacement of real-time terminal output. Improve fallback terminal API handling for older IDEA versions.
1 parent 2ea1df1 commit 92a5b3c

File tree

10 files changed

+174
-57
lines changed

10 files changed

+174
-57
lines changed

mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,13 @@ class CodingAgentExecutor(
336336
return null
337337
}
338338

339+
// 对于 Live Session,不要用分析结果替换原始输出
340+
// Live Terminal 已经在 Timeline 中显示实时输出了
341+
val isLiveSession = executionResult.metadata["isLiveSession"] == "true"
342+
if (isLiveSession) {
343+
return null
344+
}
345+
339346
// 检测内容类型
340347
val contentType = when {
341348
toolName == "glob" -> "file-list"

mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -299,25 +299,33 @@ class DocumentAgentExecutor(
299299
/**
300300
* P1: Check for long content and delegate to AnalysisAgent for summarization
301301
* NOTE: Code content (from $.code.* queries) is NOT summarized to preserve actual code
302+
* NOTE: Live Session output is NOT summarized to preserve real-time terminal output
302303
*/
303304
private suspend fun checkForLongContent(
304305
toolName: String,
305306
output: String,
306307
executionResult: ToolExecutionResult
307308
): ToolResult.AgentResult? {
308-
309+
309310
if (subAgentManager == null) {
310311
return null
311312
}
312-
313+
314+
// 对于 Live Session,不要用分析结果替换原始输出
315+
// Live Terminal 已经在 Timeline 中显示实时输出了
316+
val isLiveSession = executionResult.metadata["isLiveSession"] == "true"
317+
if (isLiveSession) {
318+
return null
319+
}
320+
313321
val isCodeContent = output.contains("📘 class ") ||
314322
output.contains("⚡ fun ") ||
315323
output.contains("Found") && output.contains("entities") ||
316324
output.contains("class ") && output.contains("{") ||
317325
output.contains("fun ") && output.contains("(") ||
318326
output.contains("def ") && output.contains(":") ||
319327
output.contains("function ") && output.contains("{")
320-
328+
321329
val contentType = when {
322330
isCodeContent -> "code" // Don't summarize code!
323331
toolName == "docql" -> "document-content"
@@ -326,23 +334,23 @@ class DocumentAgentExecutor(
326334
output.contains("```") -> "code"
327335
else -> "text"
328336
}
329-
337+
330338
// Skip summarization for code content - we want to show actual code
331339
if (contentType == "code") {
332340
logger.debug { "📊 Skipping summarization for code content (${output.length} chars)" }
333341
return null
334342
}
335-
343+
336344
// Build metadata
337345
val metadata = mutableMapOf<String, String>()
338346
metadata["toolName"] = toolName
339347
metadata["executionId"] = executionResult.executionId
340348
metadata["success"] = executionResult.isSuccess.toString()
341-
349+
342350
executionResult.metadata.forEach { (key, value) ->
343351
metadata["tool_$key"] = value
344352
}
345-
353+
346354
return subAgentManager.checkAndHandleLongContent(
347355
content = output,
348356
contentType = contentType,

mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,31 @@ sealed class TimelineItem(
164164
/**
165165
* Live terminal session - connected to a PTY process for real-time output.
166166
* This is only used on platforms that support PTY (JVM with JediTerm).
167+
*
168+
* When the session completes, exitCode and executionTimeMs will be set.
169+
* The UI can use these to show completion status without creating a separate TerminalOutputItem.
167170
*/
168171
data class LiveTerminalItem(
169172
val sessionId: String,
170173
val command: String,
171174
val workingDirectory: String?,
172175
val ptyHandle: Any?, // Platform-specific: on JVM this is a PtyProcess
176+
val exitCode: Int? = null, // null = still running, non-null = completed
177+
val executionTimeMs: Long? = null, // null = still running
178+
val output: String? = null, // Captured output when completed (optional)
173179
override val timestamp: Long = Platform.getCurrentTimestamp(),
174180
override val id: String = generateId()
175-
) : TimelineItem(timestamp, id)
181+
) : TimelineItem(timestamp, id) {
182+
/**
183+
* Check if the terminal session is still running
184+
*/
185+
fun isRunning(): Boolean = exitCode == null
186+
187+
/**
188+
* Check if the terminal session completed successfully
189+
*/
190+
fun isSuccess(): Boolean = exitCode == 0
191+
}
176192

177193
companion object {
178194
/**

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,23 +213,29 @@ class JewelRenderer : BaseRenderer() {
213213

214214
// For shell commands, check if it's a live session
215215
val isLiveSession = metadata["isLiveSession"] == "true"
216-
val liveExitCode = metadata["live_exit_code"]?.toIntOrNull()
216+
val sessionId = metadata["sessionId"]
217+
val liveExitCode = metadata["exit_code"]?.toIntOrNull()
217218

218219
if (toolType == ToolType.Shell && output != null) {
219220
val exitCode = liveExitCode ?: (if (success) 0 else 1)
220221
val executionTimeMs = executionTime ?: 0L
221222
val command = currentToolCallInfo?.details?.removePrefix("Executing: ") ?: "unknown"
222223

223-
if (isLiveSession) {
224-
// Add terminal output after live terminal
225-
addTimelineItem(
226-
TimelineItem.TerminalOutputItem(
227-
command = command,
228-
output = fullOutput ?: output,
229-
exitCode = exitCode,
230-
executionTimeMs = executionTimeMs
231-
)
232-
)
224+
if (isLiveSession && sessionId != null) {
225+
// Update the existing LiveTerminalItem with completion status
226+
_timeline.update { items ->
227+
items.map { item ->
228+
if (item is TimelineItem.LiveTerminalItem && item.sessionId == sessionId) {
229+
item.copy(
230+
exitCode = exitCode,
231+
executionTimeMs = executionTimeMs,
232+
output = fullOutput ?: output
233+
)
234+
} else {
235+
item
236+
}
237+
}
238+
}
233239
} else {
234240
// Replace the last tool call with terminal output
235241
_timeline.update { items ->

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ object TerminalApiCompat {
9696

9797
/**
9898
* Fallback approach for older IDEA versions or when new API is not available.
99-
* Uses ToolWindowManager to open terminal tool window.
99+
* Uses TerminalToolWindowManager to create shell widget and execute command.
100100
*/
101101
private fun tryFallbackTerminalApi(
102102
project: Project,
@@ -105,23 +105,67 @@ object TerminalApiCompat {
105105
requestFocus: Boolean
106106
): Boolean {
107107
try {
108-
// Try to use ToolWindowManager to activate terminal
108+
// Try to use TerminalToolWindowManager (Classic Terminal API)
109+
val managerClass = Class.forName("org.jetbrains.plugins.terminal.TerminalToolWindowManager")
110+
val getInstanceMethod = managerClass.getMethod("getInstance", Project::class.java)
111+
val manager = getInstanceMethod.invoke(null, project)
112+
113+
// Create shell widget
114+
val effectiveTabName = tabName ?: "AutoDev: $command"
115+
val createShellWidgetMethod = managerClass.getMethod(
116+
"createShellWidget",
117+
String::class.java, // workingDirectory
118+
String::class.java, // tabName
119+
Boolean::class.javaPrimitiveType, // requestFocus
120+
Boolean::class.javaPrimitiveType // deferSessionStartUntilUiShown
121+
)
122+
123+
val widget = createShellWidgetMethod.invoke(
124+
manager,
125+
null, // workingDirectory (use default)
126+
effectiveTabName,
127+
requestFocus,
128+
false // don't defer, start immediately
129+
)
130+
131+
// Execute command on the widget
132+
val widgetClass = widget.javaClass
133+
val executeCommandMethod = widgetClass.getMethod("executeCommand", String::class.java)
134+
executeCommandMethod.invoke(widget, command)
135+
136+
LOG.info("Successfully executed command in terminal using fallback API: $command")
137+
return true
138+
} catch (e: ClassNotFoundException) {
139+
LOG.warn("TerminalToolWindowManager not found, trying basic activation")
140+
return tryBasicTerminalActivation(project, requestFocus)
141+
} catch (e: NoSuchMethodException) {
142+
LOG.warn("Terminal API method not found: ${e.message}")
143+
return tryBasicTerminalActivation(project, requestFocus)
144+
} catch (e: Exception) {
145+
LOG.warn("Fallback terminal API failed: ${e.message}", e)
146+
return tryBasicTerminalActivation(project, requestFocus)
147+
}
148+
}
149+
150+
/**
151+
* Last resort: just activate the terminal tool window without executing command.
152+
*/
153+
private fun tryBasicTerminalActivation(project: Project, requestFocus: Boolean): Boolean {
154+
return try {
109155
val toolWindowManager = com.intellij.openapi.wm.ToolWindowManager.getInstance(project)
110156
val terminalToolWindow = toolWindowManager.getToolWindow("Terminal")
111-
112-
if (terminalToolWindow != null) {
113-
if (requestFocus) {
114-
terminalToolWindow.activate(null)
115-
}
116-
LOG.info("Activated Terminal tool window (command not sent): $command")
157+
158+
if (terminalToolWindow != null && requestFocus) {
159+
terminalToolWindow.activate(null)
160+
LOG.info("Activated Terminal tool window (command not sent)")
117161
return true
118162
}
119-
163+
120164
LOG.warn("Terminal tool window not found")
121-
return false
165+
false
122166
} catch (e: Exception) {
123-
LOG.warn("Fallback terminal API failed: ${e.message}", e)
124-
return false
167+
LOG.warn("Basic terminal activation failed: ${e.message}", e)
168+
false
125169
}
126170
}
127171

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,10 @@ fun RenderMessageItem(
222222
sessionId = timelineItem.sessionId,
223223
command = timelineItem.command,
224224
workingDirectory = timelineItem.workingDirectory,
225-
ptyHandle = timelineItem.ptyHandle
225+
ptyHandle = timelineItem.ptyHandle,
226+
exitCode = timelineItem.exitCode,
227+
executionTimeMs = timelineItem.executionTimeMs,
228+
output = timelineItem.output
226229
)
227230
}
228231
}
@@ -232,13 +235,20 @@ fun RenderMessageItem(
232235
* Platform-specific live terminal display.
233236
* On JVM with PTY support: Renders an interactive terminal widget
234237
* On other platforms: Shows a message that live terminal is not available
238+
*
239+
* @param exitCode Exit code when completed (null if still running)
240+
* @param executionTimeMs Execution time when completed (null if still running)
241+
* @param output Captured output when completed (null if still running or not captured)
235242
*/
236243
@Composable
237244
expect fun LiveTerminalItem(
238245
sessionId: String,
239246
command: String,
240247
workingDirectory: String?,
241-
ptyHandle: Any?
248+
ptyHandle: Any?,
249+
exitCode: Int? = null,
250+
executionTimeMs: Long? = null,
251+
output: String? = null
242252
)
243253

244254
@Composable

mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.ios.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ actual fun LiveTerminalItem(
1414
sessionId: String,
1515
command: String,
1616
workingDirectory: String?,
17-
ptyHandle: Any?
17+
ptyHandle: Any?,
18+
exitCode: Int?,
19+
executionTimeMs: Long?,
20+
output: String?
1821
) {
1922
Column(modifier = androidx.compose.ui.Modifier.padding(16.dp)) {
2023
Text("Terminal functionality is not available on iOS")

mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.js.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ actual fun LiveTerminalItem(
1919
sessionId: String,
2020
command: String,
2121
workingDirectory: String?,
22-
ptyHandle: Any?
22+
ptyHandle: Any?,
23+
exitCode: Int?,
24+
executionTimeMs: Long?,
25+
output: String?
2326
) {
2427
Card(
2528
colors =

mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.jvm.kt

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,17 @@ import cc.unitmesh.devins.ui.compose.theme.AutoDevColors
3030
* - Compact header (32-36dp) to save space in timeline
3131
* - Inline status indicator
3232
* - Clean, minimal design using AutoDevColors
33+
* - Shows completion status when exitCode is provided
3334
*/
3435
@Composable
3536
actual fun LiveTerminalItem(
3637
sessionId: String,
3738
command: String,
3839
workingDirectory: String?,
39-
ptyHandle: Any?
40+
ptyHandle: Any?,
41+
exitCode: Int?,
42+
executionTimeMs: Long?,
43+
output: String?
4044
) {
4145
var expanded by remember { mutableStateOf(true) } // Auto-expand live terminal
4246
val process =
@@ -54,7 +58,8 @@ actual fun LiveTerminalItem(
5458
process?.let { ProcessTtyConnector(it) }
5559
}
5660

57-
val isRunning = process?.isAlive == true
61+
// Determine if running: if exitCode is provided, it's completed
62+
val isRunning = exitCode == null && (process?.isAlive == true)
5863

5964
Card(
6065
colors =
@@ -119,30 +124,42 @@ actual fun LiveTerminalItem(
119124
modifier = Modifier.weight(1f)
120125
)
121126

122-
// Status badge - compact
127+
// Status badge - compact, shows exit code when completed
128+
val (statusText, statusColor) = when {
129+
isRunning -> "RUNNING" to AutoDevColors.Green.c400
130+
exitCode == 0 -> "✓ EXIT 0" to AutoDevColors.Green.c400
131+
exitCode != null -> "✗ EXIT $exitCode" to AutoDevColors.Red.c400
132+
else -> "DONE" to MaterialTheme.colorScheme.onSurfaceVariant
133+
}
134+
123135
Surface(
124-
color =
125-
if (isRunning) {
126-
AutoDevColors.Green.c400.copy(alpha = 0.15f)
127-
} else {
128-
MaterialTheme.colorScheme.surfaceVariant
129-
},
136+
color = statusColor.copy(alpha = 0.15f),
130137
shape = RoundedCornerShape(10.dp),
131138
modifier = Modifier.height(20.dp)
132139
) {
133-
Text(
134-
text = if (isRunning) "RUNNING" else "DONE",
140+
Row(
135141
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
136-
color =
137-
if (isRunning) {
138-
AutoDevColors.Green.c400
139-
} else {
140-
MaterialTheme.colorScheme.onSurfaceVariant
141-
},
142-
style = MaterialTheme.typography.labelSmall,
143-
fontSize = 10.sp,
144-
fontWeight = FontWeight.Bold
145-
)
142+
horizontalArrangement = Arrangement.spacedBy(4.dp),
143+
verticalAlignment = Alignment.CenterVertically
144+
) {
145+
Text(
146+
text = statusText,
147+
color = statusColor,
148+
style = MaterialTheme.typography.labelSmall,
149+
fontSize = 10.sp,
150+
fontWeight = FontWeight.Bold
151+
)
152+
153+
// Show execution time when completed
154+
if (executionTimeMs != null) {
155+
Text(
156+
text = "${executionTimeMs}ms",
157+
color = statusColor.copy(alpha = 0.7f),
158+
style = MaterialTheme.typography.labelSmall,
159+
fontSize = 9.sp
160+
)
161+
}
162+
}
146163
}
147164
}
148165

0 commit comments

Comments
 (0)