feat(chat): add configurable message stats (#1134)

This commit is contained in:
M57 authored and GitHub committed 2026-09-16 00:00:09 +03:00
1 parent afffcd95e8
commit 529b424eb4
20 files changed
+606 -44

No files matched your search

@@ -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<ChatMessage>,
@@ -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(
@@ -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)
}
}
@@ -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",
@@ -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
@@ -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"),
)
}
}
}
}
@@ -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,
@@ -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(),
)
}
}
@@ -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),
)
}
@@ -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,
)
}
@@ -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,
)
}
}
@@ -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<ConnectionProfile> = 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) {
@@ -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),
)
}
}
+5
View File
@@ -184,6 +184,11 @@
<string name="settings_sec_chat">المحادثة</string>
<string name="settings_item_typing_effect">تأثير الكتابة</string>
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
<string name="settings_item_message_stats">إحصائيات الرسائل</string>
<string name="settings_desc_message_stats">إظهار تفاصيل الرموز وأداء التوليد في الدردشة.</string>
<string name="settings_item_user_message_tokens">رموز رسالة المستخدم</string>
<string name="settings_item_assistant_message_tokens">رموز رسالة المساعد</string>
<string name="settings_item_tokens_per_second">الرموز في الثانية (TPS)</string>
<string name="chat_agent_status_typing">الوكيل يكتب</string>
<string name="chat_agent_status_thinking">يفكّر…</string>
<string name="chat_agent_status_searching">يبحث…</string>
+5
View File
@@ -187,6 +187,11 @@
<string name="settings_sec_chat">채팅</string>
<string name="settings_item_typing_effect">타이핑 효과</string>
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
<string name="settings_item_message_stats">메시지 통계</string>
<string name="settings_desc_message_stats">채팅에서 토큰 및 생성 성능 세부정보 표시</string>
<string name="settings_item_user_message_tokens">사용자 메시지 토큰</string>
<string name="settings_item_assistant_message_tokens">어시스턴트 메시지 토큰</string>
<string name="settings_item_tokens_per_second">초당 토큰 수 (TPS)</string>
<string name="chat_agent_status_typing">에이전트 입력 중</string>
<string name="chat_agent_status_thinking">생각 중…</string>
<string name="chat_agent_status_searching">검색 중…</string>
+5
View File
@@ -184,6 +184,11 @@
<string name="settings_sec_chat">对话</string>
<string name="settings_item_typing_effect">打字效果</string>
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
<string name="settings_item_message_stats">消息统计</string>
<string name="settings_desc_message_stats">在聊天中显示令牌和生成性能详情</string>
<string name="settings_item_user_message_tokens">用户消息令牌</string>
<string name="settings_item_assistant_message_tokens">助手消息令牌</string>
<string name="settings_item_tokens_per_second">每秒令牌数 (TPS)</string>
<string name="chat_agent_status_typing">助手正在输入</string>
<string name="chat_agent_status_thinking">正在思考…</string>
<string name="chat_agent_status_searching">正在搜索…</string>
+5
View File
@@ -208,6 +208,11 @@
<string name="settings_sec_chat">Chat</string>
<string name="settings_item_typing_effect">Typing Effect</string>
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
<string name="settings_item_message_stats">Message stats</string>
<string name="settings_desc_message_stats">Show token and generation performance details in chat.</string>
<string name="settings_item_user_message_tokens">User message tokens</string>
<string name="settings_item_assistant_message_tokens">Assistant message tokens</string>
<string name="settings_item_tokens_per_second">Tokens per second (TPS)</string>
<string name="chat_agent_status_typing">Agent is typing</string>
<string name="chat_agent_status_thinking">Thinking…</string>
<string name="chat_agent_status_searching">Searching…</string>
@@ -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
@@ -165,6 +165,10 @@ class ChatViewModelTest {
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
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
@@ -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
@@ -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