diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index e78ac7f1..9a4ef7f1 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -1,12 +1,18 @@ package com.m57.hermescontrol.ui.chat.fullbleed +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.ui.chat.ChatMessage @@ -44,7 +50,17 @@ class FullBleedChatListTest { content: String = "content-$id", toolStatus: ToolStatus? = null, isStreaming: Boolean = false, - ) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming) + tokenCount: Int? = null, + tps: Double? = null, + ) = ChatMessage( + id = id, + role = role, + content = content, + toolStatus = toolStatus, + isStreaming = isStreaming, + tokenCount = tokenCount, + tps = tps, + ) private fun render( messages: List, @@ -52,6 +68,10 @@ class FullBleedChatListTest { streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage), isAgentTyping: Boolean = streamingMessage?.isStreaming == true, clarify: Boolean = false, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, + showAssistantMessageTokens: Boolean = true, + showTokensPerSecond: Boolean = true, ) { composeTestRule.setContent { val listState = LazyListState() @@ -62,6 +82,10 @@ class FullBleedChatListTest { searchState = ChatSearchState(), typingEffectEnabled = false, typingEffectDelayMs = 30, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, isLoading = false, isLoadingOlder = false, listState = listState, @@ -133,6 +157,146 @@ class FullBleedChatListTest { composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() } + @Test + fun messageStats_masterOff_hidesAllMetadata() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_userTokensOnly_showsOnlyUserTokens() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showAssistantMessageTokens = false, + showTokensPerSecond = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_assistantTokensOnly_showsOnlyAssistantTokens() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showUserMessageTokens = false, + showTokensPerSecond = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_tpsOnly_showsOnlyAssistantTps() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showUserMessageTokens = false, + showAssistantMessageTokens = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed() + } + + @Test + fun messageStats_allEnabled_showsUserTokensAssistantTokensAndTps() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0) + composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() + } + + @Test + fun messageStats_nullAndZeroValues_areHidden() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 0), + msg("a1", MessageRole.ASSISTANT, tokenCount = 0, tps = 0.0), + ), + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_streamingAssistant_hidesFinalMetadata() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = + msg( + "s1", + MessageRole.ASSISTANT, + content = "", + isStreaming = true, + tokenCount = 456, + tps = 42.5, + ), + isAgentTyping = true, + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed() + } + + @Test + fun assistantStats_wrapInsideConstrainedWidth() { + composeTestRule.setContent { + Box( + modifier = Modifier.width(220.dp).testTag("stats_host"), + ) { + FullBleedAgentMessage( + message = msg("a1", MessageRole.ASSISTANT, tokenCount = 123456789, tps = 9876.5), + messageStatsEnabled = true, + ) + } + } + composeTestRule.waitForIdle() + val host = composeTestRule.onNodeWithTag("stats_host").getUnclippedBoundsInRoot() + val token = composeTestRule.onNodeWithTag("fullbleed_token_count").getUnclippedBoundsInRoot() + val tps = composeTestRule.onNodeWithTag("fullbleed_tps").getUnclippedBoundsInRoot() + assert(token.left >= host.left) + assert(token.right <= host.right) + assert(tps.left >= host.left) + assert(tps.right <= host.right) + } + @Test fun streaming_and_clarify_stillRender() { render( diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt new file mode 100644 index 00000000..cd0082c8 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.settings.components + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ChatSectionTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() { + composeTestRule.setContent { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + messageStatsEnabled = false, + showUserMessageTokens = true, + showAssistantMessageTokens = true, + showTokensPerSecond = true, + ) + } + + composeTestRule.onNodeWithTag("settings_message_stats").assertIsOff().assertIsEnabled() + composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsOn().assertIsNotEnabled() + composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsOn().assertIsNotEnabled() + composeTestRule.onNodeWithTag("settings_tps").assertIsOn().assertIsNotEnabled() + } + + @Test + fun messageStatsMasterOn_enablesChildrenAndRoutesCallbacks() { + var masterChanges = 0 + var userChanges = 0 + var assistantChanges = 0 + var tpsChanges = 0 + composeTestRule.setContent { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + messageStatsEnabled = true, + onMessageStatsEnabledChange = { masterChanges++ }, + showUserMessageTokens = true, + onUserMessageTokensChange = { userChanges++ }, + showAssistantMessageTokens = true, + onAssistantMessageTokensChange = { assistantChanges++ }, + showTokensPerSecond = true, + onTokensPerSecondChange = { tpsChanges++ }, + ) + } + + composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick() + composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick() + composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick() + assertEquals(0, masterChanges) + assertEquals(1, userChanges) + assertEquals(1, assistantChanges) + assertEquals(1, tpsChanges) + + composeTestRule.onNodeWithTag("settings_message_stats").performClick() + assertEquals(1, masterChanges) + assertEquals(1, userChanges) + assertEquals(1, assistantChanges) + assertEquals(1, tpsChanges) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 1112d746..097094b1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -24,6 +24,10 @@ data class ServerStoreState( val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, + val messageStatsEnabled: Boolean = false, + val showUserMessageTokens: Boolean = true, + val showAssistantMessageTokens: Boolean = true, + val showTokensPerSecond: Boolean = true, // App display language. "system" = follow device locale; otherwise a BCP-47 // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. val appLanguage: String = "system", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 2cf351ff..1e23a503 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -713,6 +713,32 @@ object AuthManager { serverStore.update { it.copy(typingEffectDelayMs = delayMs) } } + // ── Message stats ─────────────────────────────────────────────────── + + fun isMessageStatsEnabled(): Boolean = serverStore.getLatestState().messageStatsEnabled + + fun setMessageStatsEnabled(enabled: Boolean) { + serverStore.update { it.copy(messageStatsEnabled = enabled) } + } + + fun isUserMessageTokensEnabled(): Boolean = serverStore.getLatestState().showUserMessageTokens + + fun setUserMessageTokensEnabled(enabled: Boolean) { + serverStore.update { it.copy(showUserMessageTokens = enabled) } + } + + fun isAssistantMessageTokensEnabled(): Boolean = serverStore.getLatestState().showAssistantMessageTokens + + fun setAssistantMessageTokensEnabled(enabled: Boolean) { + serverStore.update { it.copy(showAssistantMessageTokens = enabled) } + } + + fun isTokensPerSecondEnabled(): Boolean = serverStore.getLatestState().showTokensPerSecond + + fun setTokensPerSecondEnabled(enabled: Boolean) { + serverStore.update { it.copy(showTokensPerSecond = enabled) } + } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 6668826a..2923f3cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -95,6 +95,7 @@ import java.time.format.DateTimeFormatter * renderer (issue #866). Agent prose renders full-bleed; user messages keep * this bubble so the conversation stays scannable. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun ChatBubble( message: ChatMessage, @@ -106,6 +107,8 @@ fun ChatBubble( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -222,12 +225,12 @@ fun ChatBubble( } } if (!message.isStreaming) { - Row( + FlowRow( modifier = Modifier .align(Alignment.End) .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, + itemVerticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { @@ -257,43 +260,28 @@ fun ChatBubble( color = userBubbleTextColor.copy(alpha = 0.6f), style = MaterialTheme.typography.labelSmall, ) - if (message.tokenCount != null && message.tokenCount > 0) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_token_count"), - ) - } - if (message.tps != null && message.tps > 0.0) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tps, - TokenEstimator.formatTps(message.tps), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_tps"), - ) + if (messageStatsEnabled && showUserMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } } } } 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 9dd91ea3..671b5f37 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 @@ -671,6 +671,10 @@ fun ChatScreen( searchState = searchState, typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, + messageStatsEnabled = state.messageStatsEnabled, + showUserMessageTokens = state.showUserMessageTokens, + showAssistantMessageTokens = state.showAssistantMessageTokens, + showTokensPerSecond = state.showTokensPerSecond, maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index f164cad4..a50a5e91 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -113,6 +113,10 @@ data class ChatUiState( // Cached settings val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, + val messageStatsEnabled: Boolean = false, + val showUserMessageTokens: Boolean = true, + val showAssistantMessageTokens: Boolean = true, + val showTokensPerSecond: Boolean = true, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), // Per-command usage counts for the slash-autocomplete ranking (issue @@ -2530,6 +2534,10 @@ class ChatViewModel( state.copy( typingEffectEnabled = AuthManager.isTypingEffectEnabled(), typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), + messageStatsEnabled = AuthManager.isMessageStatsEnabled(), + showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(), + showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(), + showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index b3df7430..604d7fae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat.fullbleed import android.content.ClipData +import androidx.compose.foundation.layout.Arrangement 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 @@ -36,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -48,6 +52,7 @@ import kotlinx.coroutines.launch * trailing copy affordance. User messages keep their bubbles; this composable * is only used for ASSISTANT messages. */ +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun FullBleedAgentMessage( message: ChatMessage, @@ -60,6 +65,9 @@ internal fun FullBleedAgentMessage( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showAssistantMessageTokens: Boolean = true, + showTokensPerSecond: Boolean = true, modifier: Modifier = Modifier, ) { val textColor = MaterialTheme.colorScheme.onSurface @@ -127,8 +135,16 @@ internal fun FullBleedAgentMessage( } if (!message.isStreaming && message.content.isNotBlank()) { - Row( - verticalAlignment = Alignment.CenterVertically, + val showTokenStat = + messageStatsEnabled && showAssistantMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + val showTpsStat = + messageStatsEnabled && showTokensPerSecond && + message.tps != null && message.tps > 0.0 + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { @@ -146,7 +162,40 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (showTokenStat) { + AssistantStatItem( + value = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + testTag = "fullbleed_token_count", + ) + } + if (showTpsStat) { + AssistantStatItem( + value = + stringResource( + R.string.chat_msg_tps, + TokenEstimator.formatTps(message.tps), + ), + testTag = "fullbleed_tps", + ) + } } } } } + +@Composable +private fun AssistantStatItem( + value: String, + testTag: String, +) { + Text( + text = value, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(testTag), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 3c6a4e67..cd861d78 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -64,6 +64,10 @@ fun FullBleedChatList( searchState: ChatSearchState, typingEffectEnabled: Boolean, typingEffectDelayMs: Int, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, + showAssistantMessageTokens: Boolean = true, + showTokensPerSecond: Boolean = true, maxToolCallsPerTurn: Int? = null, isLoading: Boolean, isLoadingOlder: Boolean, @@ -184,6 +188,8 @@ fun FullBleedChatList( savingAttachmentPath = savingAttachmentPath, openingAttachmentPath = openingAttachmentPath, onImageClick = onImageClick, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, ) } } @@ -248,6 +254,9 @@ fun FullBleedChatList( openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, + messageStatsEnabled = messageStatsEnabled, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, ) } } @@ -356,6 +365,8 @@ private fun renderChatBubble( savingAttachmentPath: String?, openingAttachmentPath: String?, onImageClick: (ImageViewerModel) -> Unit, + messageStatsEnabled: Boolean, + showUserMessageTokens: Boolean, ) { ChatBubble( message = message, @@ -367,5 +378,7 @@ private fun renderChatBubble( openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 561072be..caaf22df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -199,6 +199,14 @@ internal fun SettingsChatPage( onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, chatFontScale = state.chatFontScale, onChatFontScaleChange = viewModel::onChatFontScaleChange, + messageStatsEnabled = state.messageStatsEnabled, + onMessageStatsEnabledChange = viewModel::onMessageStatsEnabledChange, + showUserMessageTokens = state.showUserMessageTokens, + onUserMessageTokensChange = viewModel::onUserMessageTokensChange, + showAssistantMessageTokens = state.showAssistantMessageTokens, + onAssistantMessageTokensChange = viewModel::onAssistantMessageTokensChange, + showTokensPerSecond = state.showTokensPerSecond, + onTokensPerSecondChange = viewModel::onTokensPerSecondChange, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 594da4d2..9f543892 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -38,6 +38,10 @@ data class SettingsUiState( val typingEffectEnabled: Boolean = false, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, + val messageStatsEnabled: Boolean = false, + val showUserMessageTokens: Boolean = true, + val showAssistantMessageTokens: Boolean = true, + val showTokensPerSecond: Boolean = true, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -80,6 +84,10 @@ class SettingsViewModel( val typingEffectEnabled = AuthManager.isTypingEffectEnabled() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val chatFontScale = AuthManager.getChatFontScale() + val messageStatsEnabled = AuthManager.isMessageStatsEnabled() + val showUserMessageTokens = AuthManager.isUserMessageTokensEnabled() + val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled() + val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -101,6 +109,10 @@ class SettingsViewModel( typingEffectEnabled = typingEffectEnabled, typingEffectDelayMs = typingEffectDelayMs, chatFontScale = chatFontScale, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -327,6 +339,26 @@ class SettingsViewModel( AuthManager.setChatFontScale(scale) } + fun onMessageStatsEnabledChange(enabled: Boolean) { + _uiState.update { it.copy(messageStatsEnabled = enabled, isSaved = false) } + AuthManager.setMessageStatsEnabled(enabled) + } + + fun onUserMessageTokensChange(enabled: Boolean) { + _uiState.update { it.copy(showUserMessageTokens = enabled, isSaved = false) } + AuthManager.setUserMessageTokensEnabled(enabled) + } + + fun onAssistantMessageTokensChange(enabled: Boolean) { + _uiState.update { it.copy(showAssistantMessageTokens = enabled, isSaved = false) } + AuthManager.setAssistantMessageTokensEnabled(enabled) + } + + fun onTokensPerSecondChange(enabled: Boolean) { + _uiState.update { it.copy(showTokensPerSecond = enabled, isSaved = false) } + AuthManager.setTokensPerSecondEnabled(enabled) + } + /** Clear all auth credentials — logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) @@ -355,6 +387,10 @@ class SettingsViewModel( AuthManager.setThemePreset(state.themePreset) AuthManager.setTypingEffectEnabled(state.typingEffectEnabled) AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs) + AuthManager.setMessageStatsEnabled(state.messageStatsEnabled) + AuthManager.setUserMessageTokensEnabled(state.showUserMessageTokens) + AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens) + AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond) ApiClient.rebuild() viewModelScope.launch(ioDispatcher) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 5318e084..43f83481 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density @@ -204,6 +205,14 @@ internal fun ChatSection( onTypingEffectDelayMsChange: (Int) -> Unit, chatFontScale: Float = 1.0f, onChatFontScaleChange: (Float) -> Unit = {}, + messageStatsEnabled: Boolean = false, + onMessageStatsEnabledChange: (Boolean) -> Unit = {}, + showUserMessageTokens: Boolean = true, + onUserMessageTokensChange: (Boolean) -> Unit = {}, + showAssistantMessageTokens: Boolean = true, + onAssistantMessageTokensChange: (Boolean) -> Unit = {}, + showTokensPerSecond: Boolean = true, + onTokensPerSecondChange: (Boolean) -> Unit = {}, ) { val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val currentIndex = @@ -369,6 +378,53 @@ internal fun ChatSection( Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.settings_item_message_stats), + style = MaterialTheme.typography.titleMedium, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.settings_desc_message_stats), + modifier = Modifier.weight(1f), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Switch( + checked = messageStatsEnabled, + onCheckedChange = onMessageStatsEnabledChange, + modifier = Modifier.testTag("settings_message_stats"), + ) + } + MessageStatsOption( + label = stringResource(R.string.settings_item_user_message_tokens), + checked = showUserMessageTokens, + enabled = messageStatsEnabled, + onCheckedChange = onUserMessageTokensChange, + testTag = "settings_user_message_tokens", + ) + MessageStatsOption( + label = stringResource(R.string.settings_item_assistant_message_tokens), + checked = showAssistantMessageTokens, + enabled = messageStatsEnabled, + onCheckedChange = onAssistantMessageTokensChange, + testTag = "settings_assistant_message_tokens", + ) + MessageStatsOption( + label = stringResource(R.string.settings_item_tokens_per_second), + checked = showTokensPerSecond, + enabled = messageStatsEnabled, + onCheckedChange = onTokensPerSecondChange, + testTag = "settings_tps", + ) + + Spacer(modifier = Modifier.height(20.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -440,3 +496,37 @@ internal fun ChatSection( } } } + +@Composable +private fun MessageStatsOption( + label: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + testTag: String, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = + MaterialTheme.typography.bodyMedium.copy( + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ), + ) + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + modifier = Modifier.testTag(testTag), + ) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 65666052..f6f3edf2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -184,6 +184,11 @@ المحادثة تأثير الكتابة تحريك ظهور الرسائل الجديدة كلمة بكلمة +إحصائيات الرسائل +إظهار تفاصيل الرموز وأداء التوليد في الدردشة. +رموز رسالة المستخدم +رموز رسالة المساعد +الرموز في الثانية (TPS) الوكيل يكتب يفكّر… يبحث… diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1b60650f..b6fbf865 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -187,6 +187,11 @@ 채팅 타이핑 효과 새 메시지를 단어 단위로 애니메이션 + 메시지 통계 + 채팅에서 토큰 및 생성 성능 세부정보 표시 + 사용자 메시지 토큰 + 어시스턴트 메시지 토큰 + 초당 토큰 수 (TPS) 에이전트 입력 중 생각 중… 검색 중… diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 7f2a0873..f4d539db 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -184,6 +184,11 @@ 对话 打字效果 逐字显示新消息动画 + 消息统计 + 在聊天中显示令牌和生成性能详情 + 用户消息令牌 + 助手消息令牌 + 每秒令牌数 (TPS) 助手正在输入 正在思考… 正在搜索… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7308260..4c5fc61b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,6 +208,11 @@ Chat Typing Effect Animate new messages word by word + Message stats + Show token and generation performance details in chat. + User message tokens + Assistant message tokens + Tokens per second (TPS) Agent is typing Thinking… Searching… diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index da758fe5..604362b0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -89,6 +89,10 @@ class ChatUpdateCommandTest { every { AuthManager.getToken() } returns "test-token" every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 7b2d7811..c828bca0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -165,6 +165,10 @@ class ChatViewModelTest { every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null @@ -4140,13 +4144,16 @@ class ChatViewModelTest { fun testRefreshSettings_updatesUiState() = runTest { // Given the default setup, init{} already calls refreshSettings() once, - // so the initial state reflects the setUp defaults (typingEffectEnabled=true, - // typingEffectDelayMs=30). + // so the initial state reflects the setUp defaults. val viewModel = createViewModel() advanceUntilIdle() with(viewModel.uiState.value) { assertTrue(typingEffectEnabled) assertEquals(30, typingEffectDelayMs) + assertFalse(messageStatsEnabled) + assertTrue(showUserMessageTokens) + assertTrue(showAssistantMessageTokens) + assertTrue(showTokensPerSecond) } // When settings change after construction and refreshSettings() is re-invoked, @@ -4154,6 +4161,10 @@ class ChatViewModelTest { // re-reads AuthManager live (the real regression scenario). every { AuthManager.isTypingEffectEnabled() } returns false every { AuthManager.getTypingEffectDelayMs() } returns 50 + every { AuthManager.isMessageStatsEnabled() } returns true + every { AuthManager.isUserMessageTokensEnabled() } returns false + every { AuthManager.isAssistantMessageTokensEnabled() } returns false + every { AuthManager.isTokensPerSecondEnabled() } returns false viewModel.refreshSettings() advanceUntilIdle() @@ -4161,6 +4172,10 @@ class ChatViewModelTest { val state = viewModel.uiState.value assertFalse(state.typingEffectEnabled) assertEquals(50, state.typingEffectDelayMs) + assertTrue(state.messageStatsEnabled) + assertFalse(state.showUserMessageTokens) + assertFalse(state.showAssistantMessageTokens) + assertFalse(state.showTokensPerSecond) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 6b09f2f5..9df5f047 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest { every { AuthManager.getToken() } returns "test-token" every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isRestoreLastSession() } returns false every { AuthManager.getLastOpenedSessionId() } returns null diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index b23b6ec1..bc42cec4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -73,6 +73,10 @@ class SettingsViewModelTest { every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getChatFontScale() } returns 1.0f + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" @@ -85,6 +89,10 @@ class SettingsViewModelTest { every { AuthManager.setTypingEffectEnabled(any()) } returns Unit every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit every { AuthManager.setChatFontScale(any()) } returns Unit + every { AuthManager.setMessageStatsEnabled(any()) } returns Unit + every { AuthManager.setUserMessageTokensEnabled(any()) } returns Unit + every { AuthManager.setAssistantMessageTokensEnabled(any()) } returns Unit + every { AuthManager.setTokensPerSecondEnabled(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } answers { storedSelectedProfileId = firstArg() } @@ -170,6 +178,46 @@ class SettingsViewModelTest { verify { AuthManager.setChatFontScale(1.15f) } } + @Test + fun testMessageStats_loadsAndTogglesIndependently() = + runTest { + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns false + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns false + + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(false, viewModel.uiState.value.messageStatsEnabled) + assertEquals(false, viewModel.uiState.value.showUserMessageTokens) + assertEquals(true, viewModel.uiState.value.showAssistantMessageTokens) + assertEquals(false, viewModel.uiState.value.showTokensPerSecond) + + viewModel.onMessageStatsEnabledChange(true) + assertEquals(true, viewModel.uiState.value.messageStatsEnabled) + verify { AuthManager.setMessageStatsEnabled(true) } + verify(exactly = 0) { AuthManager.setUserMessageTokensEnabled(any()) } + verify(exactly = 0) { AuthManager.setAssistantMessageTokensEnabled(any()) } + verify(exactly = 0) { AuthManager.setTokensPerSecondEnabled(any()) } + + viewModel.onUserMessageTokensChange(true) + viewModel.onAssistantMessageTokensChange(false) + viewModel.onTokensPerSecondChange(true) + assertEquals(true, viewModel.uiState.value.showUserMessageTokens) + assertEquals(false, viewModel.uiState.value.showAssistantMessageTokens) + assertEquals(true, viewModel.uiState.value.showTokensPerSecond) + verify { AuthManager.setUserMessageTokensEnabled(true) } + verify { AuthManager.setAssistantMessageTokensEnabled(false) } + verify { AuthManager.setTokensPerSecondEnabled(true) } + + viewModel.onMessageStatsEnabledChange(false) + assertEquals(false, viewModel.uiState.value.messageStatsEnabled) + assertEquals(true, viewModel.uiState.value.showUserMessageTokens) + assertEquals(false, viewModel.uiState.value.showAssistantMessageTokens) + assertEquals(true, viewModel.uiState.value.showTokensPerSecond) + verify(exactly = 2) { AuthManager.setMessageStatsEnabled(any()) } + } + @Test fun testLoadSettings_noSelectedProfile_renameEmpty() { every { AuthManager.getConnectionProfiles() } returns testProfiles