diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 2266fede..77d9a828 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -87,3 +87,19 @@ val SkeletonBase = Color(0xFF1C1C26) val SkeletonHighlight = Color(0xFF2F2F3D) val SkeletonBaseLight = Color(0xFFE6E3EE) val SkeletonHighlightLight = Color(0xFFEFEDF4) + +// ── Syntax highlighting tokens (code blocks) ───────────────────────────── +// VS Code Dark+ palette — good general readability across all presets. +// These are consumed exclusively by CodeBlockCard (MessageCards.kt). + +val CodeKeyword = Color(0xFF569CD6) // blue — for val, fun, class, etc. +val CodeString = Color(0xFFCE9178) // orange — for "string literals" +val CodeComment = Color(0xFF6A9955) // green — for // comments +val CodeNumber = Color(0xFFB5CEA8) // light green — for 42, 0xFF +val CodePunctuation = Color(0xFFD4D4D4) // gray — for {, (, ;, etc. + +// ── Code block / terminal surface colours (issue #659) ─────────────── +val CodeTerminalBg = Color(0xFF1E1E1E) +val CodeTerminalBorder = Color(0xFF333333) +val CodeTerminalText = Color(0xFFD4D4D4) +val CodeTerminalMuted = Color(0xFF808080) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 24e670ab..72652381 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -35,6 +35,8 @@ data class ChatMessage( val toolName: String? = null, val toolStatus: ToolStatus? = null, val approvalInfo: ApprovalInfo? = null, + /** Transient clarify request data — when present, renders [ClarifyBubble] inline. */ + val clarifyInfo: ClarifyUi? = null, /** Files attached to this message — shown inline in the bubble. */ val attachments: List? = null, /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ec9ce0ba..6fd7d5c8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -438,6 +438,9 @@ fun ChatScreen( onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, viewModel = viewModel, subagentIndicators = state.subagentIndicators, + clarifyRequest = state.clarifyRequest, + onRespondClarify = viewModel::respondToClarify, + onDismissClarify = viewModel::dismissClarify, ) // Loading overlay diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 88b7c760..d5c5c3f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -1,10 +1,7 @@ package com.m57.hermescontrol.ui.chat -import android.content.ClipData -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -19,30 +16,20 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -56,12 +43,9 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val TABLE_COL_WIDTH = 160.dp @@ -105,7 +89,11 @@ fun MarkdownText( for (block in blocks) { when (block) { is MdBlock.Code -> { - CodeBlockCard(code = block.code, textColor = textColor) + com.m57.hermescontrol.ui.chat.components.CodeBlockCard( + code = block.code, + language = block.language, + onCopy = { /* clipboard handled internally */ }, + ) } is MdBlock.Hr -> { @@ -354,73 +342,6 @@ fun MarkdownText( } } -/** - * Fenced code block rendered as a card: monospaced text with horizontal scroll and a copy button. - */ -@Composable -private fun CodeBlockCard( - code: String, - textColor: Color, -) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - - // Auto-dismiss the "copied" checkmark after 2s - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false - } - } - - Surface( - shape = RoundedCornerShape(8.dp), - color = textColor.copy(alpha = 0.06f), - border = BorderStroke(1.dp, textColor.copy(alpha = 0.15f)), - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) { - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, code))) - } - copied = true - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(15.dp), - tint = textColor.copy(alpha = 0.7f), - ) - } - } - Text( - text = code, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = textColor, - softWrap = false, - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } - } -} - @Composable private fun MarkdownTable( block: MdBlock.Table, @@ -505,12 +426,23 @@ internal fun parseBlocks(src: String): List { when { line.startsWith("```") -> { + val lang = line.removePrefix("```").trim().ifBlank { null } val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") } if (end != null) { - blocks.add(MdBlock.Code(lines.subList(i + 1, end).joinToString("\n"))) + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, end).joinToString("\n"), + language = lang, + ), + ) i = end + 1 } else { - blocks.add(MdBlock.Code(lines.subList(i + 1, lines.size).joinToString("\n"))) + blocks.add( + MdBlock.Code( + code = lines.subList(i + 1, lines.size).joinToString("\n"), + language = lang, + ), + ) i = lines.size } } @@ -953,6 +885,7 @@ internal fun parseInline( internal sealed interface MdBlock { data class Code( val code: String, + val language: String? = null, ) : MdBlock data class Heading( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt index 85977ef8..0d3dad90 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt @@ -1,23 +1,16 @@ package com.m57.hermescontrol.ui.chat.components -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -26,111 +19,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.ClarifyUi - -/** Dialog shown when the agent asks the user to clarify between options. */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ClarifyDialog( - clarify: ClarifyUi, - onOptionSelected: (String) -> Unit, - onDismiss: () -> Unit, -) { - var typedText by rememberSaveable(clarify.text) { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.chat_clarify_title)) }, - text = { - Column(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = - Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = clarify.text) - - if (clarify.options.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.chat_clarify_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - clarify.options.forEach { option -> - Surface( - onClick = { typedText = option }, - modifier = - Modifier - .fillMaxWidth() - .testTag("clarify_option"), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.secondaryContainer, - ) { - Box( - modifier = - Modifier - .combinedClickable( - role = Role.Button, - onClick = { typedText = option }, - onLongClick = { onOptionSelected(option) }, - ).padding(horizontal = 24.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = option, - color = MaterialTheme.colorScheme.onSecondaryContainer, - style = MaterialTheme.typography.labelLarge, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = typedText, - onValueChange = { typedText = it }, - label = { Text(stringResource(R.string.chat_clarify_response)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - }, - confirmButton = { - TextButton( - onClick = { - if (typedText.isNotBlank()) { - onOptionSelected(typedText) - } - }, - enabled = typedText.isNotBlank(), - ) { - Text(stringResource(R.string.chat_send)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.chat_dismiss)) - } - }, - ) -} /** * Secure password dialog for a pending `sudo.request` (issue #524). diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt deleted file mode 100644 index a4560204..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -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.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.outlined.Psychology -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.graphics.graphicsLayer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.SubagentIndicator - -/** - * Animated "🤔 Thinking…" card shown while the assistant is thinking - * but has not yet produced visible text. - */ -@Composable -fun ThinkingIndicator(thinkingText: String) { - val infiniteTransition = rememberInfiniteTransition(label = "thinking") - val alpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - animation = tween(600, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "thinking_alpha", - ) - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - contentAlignment = Alignment.CenterStart, - ) { - Card( - colors = - CardDefaults.cardColors( - containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f), - ), - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "🤔", - fontSize = 14.sp, - modifier = Modifier.graphicsLayer { this.alpha = alpha }, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = - if (thinkingText.isNotBlank()) { - val display = thinkingText.takeLast(100) - stringResource(R.string.chat_thinking_param, display) - } else { - stringResource(R.string.chat_thinking) - }, - style = - MaterialTheme.typography.bodySmall.copy( - color = LocalHermesStatusColors.current.info, - ), - maxLines = 2, - modifier = Modifier.animateContentSize(), - ) - } - } - } -} - -/** - * Collapsible card showing the assistant's reasoning trace - * (reasoning-model thinking steps) before the final answer. - */ -@Composable -fun ReasoningIndicator(reasoningText: String) { - var expanded by remember { mutableStateOf(false) } - val statusColors = LocalHermesStatusColors.current - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .clickable( - role = Role.Button, - onClick = { expanded = !expanded }, - ), - colors = - CardDefaults.cardColors( - containerColor = statusColors.infoContainer.copy(alpha = 0.5f), - ), - shape = RoundedCornerShape(12.dp), - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Outlined.Psychology, - contentDescription = stringResource(R.string.chat_reasoning), - tint = statusColors.info, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.chat_reasoning), - style = MaterialTheme.typography.labelMedium, - color = statusColors.info, - ) - Spacer(Modifier.weight(1f)) - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = - stringResource( - if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand, - ), - tint = statusColors.info, - modifier = Modifier.size(18.dp), - ) - } - AnimatedVisibility(visible = expanded) { - Text( - text = reasoningText, - style = MaterialTheme.typography.bodySmall, - color = statusColors.info, - modifier = Modifier.padding(top = 4.dp), - ) - } - } - } -} - -/** - * Row showing a subagent task progress chip in the message list. - */ -@Composable -fun SubagentIndicatorRow( - indicator: SubagentIndicator, - modifier: Modifier = Modifier, -) { - Surface( - modifier = - modifier - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "🔀", - style = MaterialTheme.typography.bodySmall, - ) - - val taskProgressText = - if (indicator.taskIndex != null && indicator.taskCount != null) { - " (${indicator.taskIndex}/${indicator.taskCount})" - } else { - "" - } - - val goalText = indicator.goal ?: "Subagent task" - val textPreview = - if (!indicator.text.isNullOrEmpty()) { - ": ${indicator.text}" - } else if (!indicator.summary.isNullOrEmpty()) { - ": ${indicator.summary}" - } else { - "" - } - - Text( - text = "$goalText$taskProgressText$textPreview", - style = - MaterialTheme.typography.bodySmall.copy( - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 8874b6bd..f78c14fd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -168,15 +168,6 @@ fun ChatLifecycleEffects( } } - // Clarify dialog - clarifyRequest?.let { clarify -> - ClarifyDialog( - clarify = clarify, - onOptionSelected = viewModel::respondToClarify, - onDismiss = viewModel::dismissClarify, - ) - } - // Sudo / secret prompt dialogs (issue #524) sudoPrompt?.let { prompt -> SudoPromptDialog( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index f77b7e2f..7c3991cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -2,8 +2,10 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -19,17 +21,13 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.common.EmptyState /** * The chat message list. - * - * Lay children out vertically so the search bar occupies real layout space - * ABOVE the message list. Without this container the call site is a Box, - * which overlays the LazyColumn on top of the search AnimatedVisibility and - * swallows every tap on the bar (bar visible but not clickable). */ @Composable fun ChatMessageList( @@ -51,6 +49,9 @@ fun ChatMessageList( onLastAnimatedMessageIdChange: (String?) -> Unit, viewModel: ChatViewModel, subagentIndicators: List = emptyList(), + clarifyRequest: ClarifyUi? = null, + onRespondClarify: ((String) -> Unit)? = null, + onDismissClarify: (() -> Unit)? = null, ) { if (messages.isEmpty() && !isLoading) { Box( @@ -91,8 +92,14 @@ fun ChatMessageList( val isLastMessage = index == messages.lastIndex val isAssistant = message.role == MessageRole.ASSISTANT + // Reasoning card — rendered inline in the same list item so it + // stays visible during AND after streaming if (isAssistant && message.reasoningText.isNotBlank()) { - ReasoningIndicator(message.reasoningText) + Spacer(modifier = Modifier.height(2.dp)) + ReasoningCard( + reasoningText = message.reasoningText, + isStreaming = message.isStreaming, + ) } if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && @@ -120,9 +127,15 @@ fun ChatMessageList( // Streaming message streamingMessage?.let { streaming -> item(key = "streaming-${streaming.id}") { + // Reasoning card — rendered inline in the same streaming item if (streaming.reasoningText.isNotBlank()) { - ReasoningIndicator(streaming.reasoningText) + Spacer(modifier = Modifier.height(2.dp)) + ReasoningCard( + reasoningText = streaming.reasoningText, + isStreaming = streaming.isStreaming, + ) } + if (typingEffectEnabled && streaming.isStreaming) { StreamingBubbleWithTypingEffect( streaming = streaming, @@ -140,19 +153,31 @@ fun ChatMessageList( } } - // Thinking indicator + // Typing indicator — bouncing dots if (isThinking) { - item(key = "thinking") { - ThinkingIndicator(thinkingText) + item(key = "typing_indicator") { + TypingIndicator() } } - // Subagent indicators + // Subagent indicators — SubagentCard replaces SubagentIndicatorRow items( items = subagentIndicators, key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" }, ) { indicator -> - SubagentIndicatorRow(indicator = indicator) + SubagentCard(indicator = indicator) + } + + // Clarify bubble — rendered at the very bottom + if (clarifyRequest != null) { + item(key = "clarify_bubble") { + ClarifyBubble( + text = clarifyRequest.text, + options = clarifyRequest.options, + onOptionSelected = { option -> onRespondClarify?.invoke(option) }, + onDismiss = { onDismissClarify?.invoke() }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt new file mode 100644 index 00000000..63511519 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -0,0 +1,564 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +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.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.CodeComment +import com.m57.hermescontrol.theme.CodeKeyword +import com.m57.hermescontrol.theme.CodeNumber +import com.m57.hermescontrol.theme.CodePunctuation +import com.m57.hermescontrol.theme.CodeString +import com.m57.hermescontrol.theme.CodeTerminalBg +import com.m57.hermescontrol.theme.CodeTerminalBorder +import com.m57.hermescontrol.theme.CodeTerminalMuted +import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.ui.chat.SubagentIndicator + +// ── ReasoningCard ───────────────────────────────────────────────────────── + +/** + * Dark collapsible card showing the assistant's reasoning trace + * (reasoning-model thinking steps) before the final answer. + * + * Collapsed: "🧠 Reasoning · {N} steps" with chevron. + * Expanded: full reasoning text in monospace bodySmall. + * Streaming: pulsing indicator at bottom while [isStreaming]. + */ +@Composable +fun ReasoningCard( + reasoningText: String, + isStreaming: Boolean = false, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } + + Card( + modifier = + modifier + .fillMaxWidth() + .then(if (!isStreaming) Modifier.animateContentSize() else Modifier) + .testTag("reasoning_card"), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + shape = RoundedCornerShape(12.dp), + onClick = { expanded = !expanded }, + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "🧠", + fontSize = 14.sp, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Reasoning · $stepCount steps", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + AnimatedVisibility(visible = expanded) { + Column { + Text( + text = reasoningText, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + if (isStreaming) { + ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp)) + } + } + } + } + } +} + +/** + * Small pulsing dot shown at the bottom of [ReasoningCard] while streaming. + */ +@Composable +private fun ReasoningPulsingDot(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse") + val pulseSpec: InfiniteRepeatableSpec = + remember { + infiniteRepeatable( + animation = tween(600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = pulseSpec, + label = "reasoning_dot_alpha", + ) + Box( + modifier = + modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { this.alpha = alpha } + .testTag("reasoning_pulsing_dot"), + ) { + androidx.compose.material3.Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) {} + } +} + +// ── CodeBlockCard ───────────────────────────────────────────────────────── + +/** + * Terminal-style card for a fenced code block with syntax highlighting, + * a language badge, and a copy button. + */ +@Composable +fun CodeBlockCard( + code: String, + language: String?, + onCopy: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Surface( + modifier = + modifier + .fillMaxWidth() + .testTag("code_block"), + shape = RoundedCornerShape(8.dp), + color = CodeTerminalBg, + border = BorderStroke(1.dp, CodeTerminalBorder), + ) { + Column { + // Header row: language badge (left) + copy button (right) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!language.isNullOrBlank()) { + Text( + text = language.uppercase(), + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = CodeTerminalMuted, + ), + ) + } + Spacer(Modifier.weight(1f)) + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + kotlinx.coroutines.delay(2000) + copied = false + } + } + IconButton( + onClick = { + copyToClipboard(context, code) + copied = true + onCopy(code) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy code", + tint = CodeTerminalMuted, + modifier = Modifier.size(14.dp), + ) + } + } + // Code content with syntax highlighting + val highlighted = remember(code) { highlightSyntax(code) } + Text( + text = highlighted, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = CodeTerminalText, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } +} + +// Hoisted token patterns for syntax highlighting — compiled once. +private val HIGHLIGHT_TOKENS = + listOf( + TokenPattern(Regex("""//[^\n]*"""), CodeComment), + TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), + TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), + TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), + TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), + TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber), + TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber), + TokenPattern( + Regex( + """\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" + + """override|private|protected|public|internal|import|package|""" + + """if|else|when|for|while|do|return|throw|try|catch|finally|""" + + """true|false|null|this|super|is|in|as|typealias|companion|""" + + """init|constructor|by|get|set|field|value|suspend|inline|""" + + """infix|operator|tailrec|external|annotation)\b""", + ), + CodeKeyword, + ), + TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation), + ) + +/** + * Builds an [AnnotatedString] from [code] with syntax highlighting colours + * applied via token regexes. Covers keywords, strings, comments, numbers, + * and punctuation — everything else remains the default light-grey. + */ +internal fun highlightSyntax(code: String): AnnotatedString = + buildAnnotatedString { + val tokens = HIGHLIGHT_TOKENS + var lastIndex = 0 + val matches = mutableListOf>() + + for (pattern in tokens) { + for (match in pattern.regex.findAll(code)) { + matches.add(match.range to pattern.color) + } + } + + matches.sortBy { it.first.first } + // Resolve overlaps: later-in-text wins for same-pos, else first-match + val resolved = mutableListOf>() + for (m in matches) { + if (resolved.isEmpty()) { + resolved.add(m) + } else { + val last = resolved.last() + if (m.first.first >= last.first.last + 1) { + // Non-overlapping — safe add + resolved.add(m) + } else if (m.first.first > last.first.first) { + // Overlap, this match starts later — it wins the overlapping portion + // Keep the last match's pre-overlap, then replace + resolved.removeLast() + // Split: keep text before overlap from last match + if (last.first.first < m.first.first) { + resolved.add(last.first.first.. m.first.last) { + resolved.add(m.first.last + 1..last.first.last to last.second) + } + } + // else same start position — first match wins, skip this one + } + } + + var pos = 0 + for ((range, color) in resolved) { + if (range.first > pos) { + append(code.substring(pos, range.first)) + } + withStyle(SpanStyle(color = color)) { + append(code.substring(range.first, range.last + 1)) + } + pos = range.last + 1 + } + if (pos < code.length) { + append(code.substring(pos)) + } + } + +private data class TokenPattern(val regex: Regex, val color: Color) + +private fun copyToClipboard( + context: Context, + text: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) +} + +// ── ClarifyBubble ───────────────────────────────────────────────────────── + +/** + * Centered dashed-border bubble showing a clarify question with + * selectable option chips and a dismiss button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ClarifyBubble( + text: String, + options: List, + onOptionSelected: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var typedText by remember { mutableStateOf("") } + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + .testTag("clarify_bubble"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (options.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + options.forEach { option -> + SuggestionChip( + onClick = { onOptionSelected(option) }, + label = { Text(option) }, + colors = + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) + } + } + } + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = typedText, + onValueChange = { typedText = it }, + label = { Text("Your response") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + FilledTonalButton( + onClick = { + if (typedText.isNotBlank()) { + onOptionSelected(typedText) + typedText = "" + } + }, + enabled = typedText.isNotBlank(), + ) { + Text("Send") + } + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + } + } +} + +// ── SubagentCard ────────────────────────────────────────────────────────── + +/** + * Inline card showing subagent task progress. + * + * - While running: [CircularProgressIndicator] + goal text on [tertiaryContainer]. + * - On completion: ✅ checkmark + summary text. + */ +@Composable +fun SubagentCard( + indicator: SubagentIndicator, + modifier: Modifier = Modifier, +) { + val isComplete = indicator.type == "subagent.complete" + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp).testTag("subagent_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isComplete) { + Text(text = "✅", fontSize = 14.sp) + } else { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + Spacer(Modifier.width(8.dp)) + val displayText = + if (isComplete && !indicator.summary.isNullOrBlank()) { + indicator.summary + } else if (!indicator.goal.isNullOrBlank()) { + indicator.goal + } else { + "Subagent task" + } + Text( + text = displayText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +// ── TypingIndicator ─────────────────────────────────────────────────────── + +/** + * Three bouncing dots shown while the assistant is typing / thinking. + * Staggered animation: 0ms, 150ms, 300ms delay per dot. + */ +@Composable +fun TypingIndicator(modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (i in 0 until 3) { + TypingDot(delayMs = i * 150) + } + } +} + +@Composable +private fun TypingDot(delayMs: Int) { + val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs") + val typingSpec: InfiniteRepeatableSpec = + remember(delayMs) { + infiniteRepeatable( + animation = tween(400, delayMillis = delayMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -6f, + animationSpec = typingSpec, + label = "typing_dot_offset_$delayMs", + ) + val alpha by infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1f, + animationSpec = typingSpec, + label = "typing_dot_alpha_$delayMs", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { + this.translationY = offset + this.alpha = alpha + }, + ) { + androidx.compose.material3.Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) {} + } +}