Skip to content

Commit 2ea1df1

Browse files
committed
feat(idea): add Jewel-themed ANSI terminal renderer
Introduce a custom ANSI terminal renderer for IntelliJ IDEA using Jewel theming, with support for colors and formatting. Enable "Open in Terminal" for command outputs via a compatibility layer for different IDEA versions. Persist agent type preference to config to avoid UI flicker.
1 parent 10ceea3 commit 2ea1df1

File tree

9 files changed

+463
-57
lines changed

9 files changed

+463
-57
lines changed

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt

Lines changed: 60 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,45 @@ import androidx.compose.foundation.background
77
import androidx.compose.foundation.clickable
88
import androidx.compose.foundation.interaction.MutableInteractionSource
99
import androidx.compose.foundation.layout.*
10-
import androidx.compose.foundation.rememberScrollState
1110
import androidx.compose.foundation.shape.RoundedCornerShape
12-
import androidx.compose.foundation.verticalScroll
1311
import androidx.compose.runtime.*
1412
import androidx.compose.ui.Alignment
1513
import androidx.compose.ui.Modifier
1614
import androidx.compose.ui.draw.clip
17-
import androidx.compose.ui.graphics.Color
1815
import androidx.compose.ui.text.font.FontFamily
1916
import androidx.compose.ui.text.font.FontWeight
2017
import androidx.compose.ui.unit.dp
2118
import androidx.compose.ui.unit.sp
2219
import cc.unitmesh.agent.render.TimelineItem
20+
import cc.unitmesh.devins.idea.renderer.terminal.IdeaAnsiTerminalRenderer
21+
import cc.unitmesh.devins.idea.terminal.TerminalApiCompat
2322
import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons
2423
import cc.unitmesh.devins.ui.compose.theme.AutoDevColors
2524
import com.intellij.openapi.ide.CopyPasteManager
25+
import com.intellij.openapi.project.Project
2626
import org.jetbrains.jewel.foundation.theme.JewelTheme
2727
import org.jetbrains.jewel.ui.component.Icon
2828
import org.jetbrains.jewel.ui.component.Text
2929
import java.awt.datatransfer.StringSelection
3030

3131
/**
3232
* Terminal output bubble for displaying shell command results.
33-
* Shows output with scrollable area (4 lines visible), full width layout.
33+
* Uses Jewel-themed ANSI terminal renderer for proper color and formatting support.
34+
* Shows output with scrollable area, full width layout.
35+
*
36+
* Features:
37+
* - ANSI color and formatting support
38+
* - Collapsible output with header
39+
* - Copy to clipboard
40+
* - Open in native terminal (when available)
3441
*/
3542
@Composable
3643
fun IdeaTerminalOutputBubble(
3744
item: TimelineItem.TerminalOutputItem,
38-
modifier: Modifier = Modifier
45+
modifier: Modifier = Modifier,
46+
project: Project? = null
3947
) {
4048
var expanded by remember { mutableStateOf(true) }
41-
val scrollState = rememberScrollState()
42-
43-
// Auto-scroll to bottom when output changes
44-
LaunchedEffect(item.output) {
45-
scrollState.animateScrollTo(scrollState.maxValue)
46-
}
4749

4850
Box(
4951
modifier = modifier
@@ -61,52 +63,46 @@ fun IdeaTerminalOutputBubble(
6163
onExpandToggle = { expanded = !expanded },
6264
onCopy = {
6365
CopyPasteManager.getInstance().setContents(StringSelection(item.output))
66+
},
67+
onOpenInTerminal = project?.let { proj ->
68+
{ openCommandInTerminal(proj, item.command) }
6469
}
6570
)
6671

67-
// Collapsible output content with scrolling
72+
// Collapsible output content using Jewel ANSI terminal renderer
6873
AnimatedVisibility(
6974
visible = expanded,
7075
enter = expandVertically(),
7176
exit = shrinkVertically()
7277
) {
73-
Box(
78+
// Use Jewel-themed ANSI terminal renderer
79+
IdeaAnsiTerminalRenderer(
80+
ansiText = item.output,
7481
modifier = Modifier
7582
.fillMaxWidth()
76-
.heightIn(max = 120.dp) // ~4 lines at 12sp + padding
77-
.background(Color(0xFF1E1E1E))
78-
.padding(12.dp)
79-
) {
80-
if (item.output.isNotEmpty()) {
81-
Text(
82-
text = item.output,
83-
style = JewelTheme.defaultTextStyle.copy(
84-
fontSize = 12.sp,
85-
fontFamily = FontFamily.Monospace,
86-
color = AutoDevColors.Neutral.c300,
87-
lineHeight = 18.sp
88-
),
89-
modifier = Modifier.verticalScroll(scrollState)
90-
)
91-
} else {
92-
Text(
93-
text = "(No output)",
94-
style = JewelTheme.defaultTextStyle.copy(
95-
fontSize = 12.sp,
96-
fontFamily = FontFamily.Monospace,
97-
color = AutoDevColors.Neutral.c500
98-
)
99-
)
100-
}
101-
}
83+
.heightIn(min = 80.dp, max = 300.dp),
84+
maxHeight = 300,
85+
backgroundColor = AutoDevColors.Neutral.c900
86+
)
10287
}
10388
}
10489
}
10590
}
10691

92+
/**
93+
* Opens the command in IDEA's native terminal using compatibility layer.
94+
*/
95+
private fun openCommandInTerminal(project: Project, command: String) {
96+
TerminalApiCompat.openCommandInTerminal(
97+
project = project,
98+
command = command,
99+
tabName = "AutoDev: $command",
100+
requestFocus = true
101+
)
102+
}
107103

108104
/**
109-
* Header component for terminal bubble with command, status, and copy button.
105+
* Header component for terminal bubble with command, status, and action buttons.
110106
*/
111107
@Composable
112108
private fun TerminalHeader(
@@ -115,7 +111,8 @@ private fun TerminalHeader(
115111
executionTimeMs: Long,
116112
expanded: Boolean,
117113
onExpandToggle: () -> Unit,
118-
onCopy: () -> Unit
114+
onCopy: () -> Unit,
115+
onOpenInTerminal: (() -> Unit)? = null
119116
) {
120117
Row(
121118
modifier = Modifier
@@ -152,7 +149,7 @@ private fun TerminalHeader(
152149
)
153150

154151
// Command text (truncated if too long)
155-
val displayCommand = if (command.length > 60) command.take(60) + "..." else command
152+
val displayCommand = if (command.length > 50) command.take(50) + "..." else command
156153
Text(
157154
text = "$ $displayCommand",
158155
style = JewelTheme.defaultTextStyle.copy(
@@ -164,14 +161,33 @@ private fun TerminalHeader(
164161
)
165162
}
166163

167-
// Right side: Status and copy
164+
// Right side: Status and actions
168165
Row(
169166
horizontalArrangement = Arrangement.spacedBy(8.dp),
170167
verticalAlignment = Alignment.CenterVertically
171168
) {
172169
// Status badge
173170
TerminalStatusBadge(exitCode = exitCode, executionTimeMs = executionTimeMs)
174171

172+
// Open in terminal button (if available)
173+
if (onOpenInTerminal != null) {
174+
Box(
175+
modifier = Modifier
176+
.size(24.dp)
177+
.clip(RoundedCornerShape(4.dp))
178+
.background(AutoDevColors.Neutral.c700)
179+
.clickable { onOpenInTerminal() },
180+
contentAlignment = Alignment.Center
181+
) {
182+
Icon(
183+
imageVector = IdeaComposeIcons.Terminal,
184+
contentDescription = "Open in Terminal",
185+
tint = AutoDevColors.Neutral.c300,
186+
modifier = Modifier.size(14.dp)
187+
)
188+
}
189+
}
190+
175191
// Copy button
176192
Box(
177193
modifier = Modifier
@@ -206,6 +222,7 @@ private fun TerminalStatusBadge(
206222
AutoDevColors.Green.c400,
207223
"exit: 0 ${executionTimeMs}ms"
208224
)
225+
209226
else -> Triple(
210227
AutoDevColors.Red.c600.copy(alpha = 0.3f),
211228
AutoDevColors.Red.c400,
@@ -228,4 +245,3 @@ private fun TerminalStatusBadge(
228245
)
229246
}
230247
}
231-

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
1010
import androidx.compose.ui.unit.dp
1111
import androidx.compose.ui.unit.sp
1212
import cc.unitmesh.agent.render.TimelineItem
13+
import com.intellij.openapi.project.Project
1314
import org.jetbrains.jewel.foundation.theme.JewelTheme
1415
import org.jetbrains.jewel.ui.component.Text
1516

@@ -22,7 +23,8 @@ fun IdeaTimelineContent(
2223
timeline: List<TimelineItem>,
2324
streamingOutput: String,
2425
listState: LazyListState,
25-
modifier: Modifier = Modifier
26+
modifier: Modifier = Modifier,
27+
project: Project? = null
2628
) {
2729
if (timeline.isEmpty() && streamingOutput.isEmpty()) {
2830
IdeaEmptyStateMessage("Start a conversation with your AI Assistant!")
@@ -34,7 +36,7 @@ fun IdeaTimelineContent(
3436
verticalArrangement = Arrangement.spacedBy(4.dp)
3537
) {
3638
items(timeline, key = { it.id }) { item ->
37-
IdeaTimelineItemView(item)
39+
IdeaTimelineItemView(item, project)
3840
}
3941

4042
// Show streaming output
@@ -51,7 +53,7 @@ fun IdeaTimelineContent(
5153
* Dispatch timeline item to appropriate bubble component.
5254
*/
5355
@Composable
54-
fun IdeaTimelineItemView(item: TimelineItem) {
56+
fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) {
5557
when (item) {
5658
is TimelineItem.MessageItem -> {
5759
IdeaMessageBubble(
@@ -69,17 +71,18 @@ fun IdeaTimelineItemView(item: TimelineItem) {
6971
IdeaTaskCompleteBubble(item)
7072
}
7173
is TimelineItem.TerminalOutputItem -> {
72-
IdeaTerminalOutputBubble(item)
74+
IdeaTerminalOutputBubble(item, project = project)
7375
}
7476
is TimelineItem.LiveTerminalItem -> {
7577
// Live terminal not supported in IDEA yet, show placeholder
7678
IdeaTerminalOutputBubble(
77-
TimelineItem.TerminalOutputItem(
79+
item = TimelineItem.TerminalOutputItem(
7880
command = item.command,
7981
output = "[Live terminal session: ${item.sessionId}]",
8082
exitCode = 0,
8183
executionTimeMs = 0
82-
)
84+
),
85+
project = project
8386
)
8487
}
8588
}

0 commit comments

Comments
 (0)