package cc.unitmesh.devins.ui.compose.agent import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.* import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import cc.unitmesh.agent.tool.shell.ShellSessionManager import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons import cc.unitmesh.devins.ui.compose.terminal.ProcessTtyConnector import cc.unitmesh.devins.ui.compose.terminal.TerminalOutputDisplay import cc.unitmesh.devins.ui.compose.terminal.TerminalWidget import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import cc.unitmesh.devins.ui.compose.terminal.PlatformTerminalDisplay import com.jediterm.terminal.ui.JediTermWidget import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.channels.Channel import java.awt.event.ActionEvent import java.awt.event.InputEvent import java.awt.event.KeyEvent as AwtKeyEvent import java.awt.Toolkit import javax.swing.AbstractAction import javax.swing.KeyStroke import kotlin.math.abs /** * JVM implementation of LiveTerminalItem. * Uses JediTerm with PTY process for real-time terminal emulation. * * Modern compact design inspired by IntelliJ Terminal plugin: * - Compact header (32-36dp) to save space in timeline * - Inline status indicator * - Clean, minimal design using AutoDevColors * - Shows completion status when exitCode is provided */ @Composable actual fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, ptyHandle: Any?, exitCode: Int?, executionTimeMs: Long?, output: String? ) { // Default: expand while running, collapse after completion. // Users can still manually expand to review final output. var expanded by rememberSaveable(sessionId) { mutableStateOf(exitCode == null) } var showFullscreen by remember { mutableStateOf(false) } val clipboardManager = LocalClipboardManager.current val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current // Cap completed output height so long logs don't expand the whole timeline. // Use screen height as a pragmatic proxy for the visible viewport. val screenHeightPx = remember { Toolkit.getDefaultToolkit().screenSize.height } val completedOutputMaxHeight = remember(screenHeightPx, density) { with(density) { (screenHeightPx * 0.8f).toDp() } } val completedOutputScrollState = rememberScrollState() // Resizable terminal height (dp) for the embedded terminal widget var terminalHeightDp by rememberSaveable(sessionId) { mutableStateOf(160f) } val minTerminalHeightDp = 80f val maxTerminalHeightDp = 520f // Mirror PTY output into ShellSessionManager so ToolOrchestrator can capture the final output // without double-reading the same PTY stream. val outputChannel = remember(sessionId) { Channel(capacity = Channel.UNLIMITED) } var managedSession by remember { mutableStateOf(null) } LaunchedEffect(sessionId) { managedSession = ShellSessionManager.getSession(sessionId) } LaunchedEffect(managedSession, outputChannel) { val session = managedSession ?: return@LaunchedEffect withContext(Dispatchers.IO) { for (chunk in outputChannel) { session.appendOutput(chunk) } } } LaunchedEffect(exitCode) { // Stop mirroring once the session is completed and the final output is captured. if (exitCode != null) { outputChannel.close() // Auto-collapse when the process completes to keep the timeline compact. expanded = false } } DisposableEffect(outputChannel) { onDispose { outputChannel.close() } } val process = remember(ptyHandle) { ptyHandle as? Process } // Create TtyConnector from the process val ttyConnector = remember(process, outputChannel) { process?.let { ProcessTtyConnector( process = it, onReadChunk = { chunk -> outputChannel.trySend(chunk) } ) } } // Determine if running: if exitCode is provided, it's completed val isRunning = exitCode == null && (process?.isAlive == true) val interrupt: () -> Unit = { try { // Ctrl+C (ETX) - terminal break / SIGINT ttyConnector?.write(byteArrayOf(3)) } catch (_: Exception) { // Best-effort fallback try { process?.outputStream?.write(byteArrayOf(3)) process?.outputStream?.flush() } catch (_: Exception) { // Ignore } } } val stop: () -> Unit = { // Mark cancel so ToolOrchestrator can label the completion correctly. ShellSessionManager.markSessionCancelledByUser(sessionId) // Try graceful interrupt first, then force-kill if still running. interrupt() coroutineScope.launch(Dispatchers.IO) { delay(1500) if (process?.isAlive == true) { process.destroyForcibly() } } } val copyCurrentOutput: () -> Unit = { coroutineScope.launch { val textToCopy = when { exitCode != null -> output.orEmpty() managedSession != null -> withContext(Dispatchers.IO) { managedSession?.getOutput().orEmpty() } else -> "" } if (textToCopy.isNotEmpty()) { clipboardManager.setText(AnnotatedString(textToCopy)) } } } // Live output snapshot for fullscreen dialog (polled from ShellSessionManager) var fullscreenOutput by remember(sessionId) { mutableStateOf("") } LaunchedEffect(showFullscreen, isRunning, exitCode, managedSession, output) { if (!showFullscreen) return@LaunchedEffect // Completed: use the captured output immediately if (exitCode != null) { fullscreenOutput = output.orEmpty() return@LaunchedEffect } // Running: poll session output for a lightweight viewer var lastLen = -1 while (showFullscreen && exitCode == null) { val text = withContext(Dispatchers.IO) { managedSession?.getOutput().orEmpty() } if (text.length != lastLen) { fullscreenOutput = text lastLen = text.length } delay(200) } } Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), shape = RoundedCornerShape(4.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { // Smooth height changes Column( modifier = Modifier .padding(8.dp) .animateContentSize() ) { // Compact header - single line with all info Row( modifier = Modifier .fillMaxWidth() .height(28.dp) .clickable { expanded = !expanded }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { // Collapse/Expand icon Icon( imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp, contentDescription = if (expanded) "Collapse" else "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(14.dp) ) // Status indicator - small dot Box( modifier = Modifier .size(7.dp) .clip(CircleShape) .background( if (isRunning) AutoDevColors.Green.c400 else MaterialTheme.colorScheme.outline ) ) // Status + time - compact inline badge val (statusIcon, statusColor) = when { isRunning -> "▶" to AutoDevColors.Green.c400 exitCode == 0 -> "✓" to AutoDevColors.Green.c400 exitCode != null -> "✗" to AutoDevColors.Red.c400 else -> "●" to MaterialTheme.colorScheme.onSurfaceVariant } Text( text = statusIcon, color = statusColor, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, fontWeight = FontWeight.Bold ) // Exit code + time (only when completed) if (exitCode != null) { Text( text = "Exit $exitCode", color = statusColor, style = MaterialTheme.typography.labelSmall, fontSize = 10.sp, fontWeight = FontWeight.Bold ) if (executionTimeMs != null) { Text( text = "·", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall, fontSize = 10.sp ) Text( text = "${executionTimeMs}ms", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall, fontSize = 10.sp ) } } // Separator Text( text = "│", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), style = MaterialTheme.typography.labelSmall, fontSize = 10.sp ) // Command text - truncated if too long Text( text = command, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, fontSize = 11.sp, maxLines = 1, modifier = Modifier.weight(1f) ) // Working directory - inline when available (only when NOT expanded to save space) if (!expanded && workingDirectory != null) { Text( text = "@ ${workingDirectory.substringAfterLast('/')}", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall, fontSize = 9.sp, maxLines = 1, modifier = Modifier.widthIn(max = 120.dp) ) } if (isRunning) { IconButton( onClick = stop, modifier = Modifier.size(22.dp) ) { Icon( imageVector = AutoDevComposeIcons.Stop, contentDescription = "Terminate", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp) ) } } IconButton( onClick = copyCurrentOutput, modifier = Modifier.size(22.dp) ) { Icon( imageVector = AutoDevComposeIcons.ContentCopy, contentDescription = "Copy output", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp) ) } IconButton( onClick = { showFullscreen = true }, modifier = Modifier.size(22.dp) ) { Icon( imageVector = AutoDevComposeIcons.Fullscreen, contentDescription = "Fullscreen", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp) ) } } // Working directory - show full path when expanded if (expanded && workingDirectory != null) { Text( text = "📁 $workingDirectory", modifier = Modifier.padding(start = 26.dp, top = 2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), style = MaterialTheme.typography.labelSmall, fontSize = 9.sp, fontFamily = FontFamily.Monospace ) } if (expanded) { Spacer(modifier = Modifier.height(4.dp)) } // Running terminal: keep the Swing terminal mounted even when collapsed so PTY output // continues to be mirrored into ShellSessionManager. if (exitCode == null && ttyConnector != null) { val effectiveHeight: Dp = if (expanded) terminalHeightDp.dp else 1.dp val effectiveAlpha = if (expanded) 1f else 0f TerminalWidget( ttyConnector = ttyConnector, modifier = Modifier .fillMaxWidth() .height(effectiveHeight) .alpha(effectiveAlpha), onTerminalReady = { widget -> widget.requestFocusInWindow() installCtrlCInterrupt(widget, onInterrupt = interrupt) } ) if (expanded) { Spacer(modifier = Modifier.height(6.dp)) TerminalResizeHandle( onDragDeltaDp = { deltaDp -> val newHeight = (terminalHeightDp + deltaDp).coerceIn(minTerminalHeightDp, maxTerminalHeightDp) if (abs(newHeight - terminalHeightDp) > 0.5f) { terminalHeightDp = newHeight } } ) } } // Completed: render captured output (only when expanded) if (expanded && exitCode != null) { val finalOutput = output ?: "" if (finalOutput.isNotEmpty()) { SelectionContainer { Box( modifier = Modifier .fillMaxWidth() .heightIn(max = completedOutputMaxHeight) .verticalScroll(completedOutputScrollState) ) { PlatformTerminalDisplay( output = finalOutput, modifier = Modifier.fillMaxWidth() ) } } } else { Text( text = "(no output captured)", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, fontSize = 11.sp, modifier = Modifier.padding(start = 30.dp, top = 4.dp) ) } } // Error (no process handle) if (expanded && exitCode == null && ttyConnector == null) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer ), modifier = Modifier.fillMaxWidth() ) { Text( text = "⚠️ Failed to connect to terminal process", modifier = Modifier.padding(8.dp), color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodyMedium ) } } } } if (showFullscreen) { LiveTerminalFullscreenDialog( title = command, isRunning = isRunning, output = fullscreenOutput, onCopy = copyCurrentOutput, onStop = stop, onClose = { showFullscreen = false } ) } } @Composable private fun TerminalResizeHandle( onDragDeltaDp: (deltaDp: Float) -> Unit ) { var isDragging by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() val alpha by animateFloatAsState( targetValue = when { isDragging -> 1f isHovered -> 0.8f else -> 0.4f }, animationSpec = tween(durationMillis = 150), label = "terminalResizeAlpha" ) Box( modifier = Modifier .height(4.dp) .fillMaxWidth() .hoverable(interactionSource) .pointerHoverIcon(PointerIcon.Crosshair) .pointerInput(Unit) { detectDragGestures( onDragStart = { isDragging = true }, onDragEnd = { isDragging = false }, onDragCancel = { isDragging = false } ) { change, dragAmount -> change.consume() onDragDeltaDp(dragAmount.y) } } ) { Spacer( modifier = Modifier .fillMaxSize() .alpha(alpha) .background(MaterialTheme.colorScheme.outlineVariant) ) } } @Composable private fun LiveTerminalFullscreenDialog( title: String, isRunning: Boolean, output: String, onCopy: () -> Unit, onStop: () -> Unit, onClose: () -> Unit ) { val focusRequester = remember { FocusRequester() } val scrollState = rememberScrollState() LaunchedEffect(Unit) { focusRequester.requestFocus() } Dialog( onDismissRequest = onClose, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = false, usePlatformDefaultWidth = false ) ) { Surface( modifier = Modifier .fillMaxWidth(0.95f) .fillMaxHeight(0.95f) .focusRequester(focusRequester) .onPreviewKeyEvent { event -> when { event.type == KeyEventType.KeyDown && event.key == Key.Escape -> { onClose(); true } event.type == KeyEventType.KeyDown && event.key == Key.W && (event.isCtrlPressed || event.isMetaPressed) -> { onClose(); true } else -> false } }, shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp ) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = AutoDevComposeIcons.Terminal, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp) ) Text( text = title, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.weight(1f) ) if (isRunning) { IconButton(onClick = onStop, modifier = Modifier.size(32.dp)) { Icon( imageVector = AutoDevComposeIcons.Stop, contentDescription = "Terminate", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp) ) } } IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) { Icon( imageVector = AutoDevComposeIcons.ContentCopy, contentDescription = "Copy output", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp) ) } IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) { Icon( imageVector = AutoDevComposeIcons.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp) ) } } Spacer(modifier = Modifier.height(8.dp)) val maxChars = 200_000 val displayText = if (output.length > maxChars) { "(truncated to last ${maxChars} chars)\n\n" + output.takeLast(maxChars) } else output Box(modifier = Modifier.fillMaxSize()) { TerminalOutputDisplay( output = displayText.ifEmpty { "(no output)" }, modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) ) } } } } } private fun installCtrlCInterrupt( widget: JediTermWidget, onInterrupt: () -> Unit ) { // Ensure Ctrl+C behaves like a terminal break (SIGINT) even if focus/shortcuts are inconsistent. val keyStroke = KeyStroke.getKeyStroke(AwtKeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK) val actionKey = "autodev.terminal.interrupt" widget.inputMap.put(keyStroke, actionKey) widget.actionMap.put( actionKey, object : AbstractAction() { override fun actionPerformed(e: ActionEvent?) { onInterrupt() } } ) }