diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt new file mode 100644 index 00000000..5e82ead7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -0,0 +1,110 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a + * subtle divider between the tool bubble and the next bubble, labeled with the + * per-turn call count against the real per-turn budget (e.g. `5/90`) so the + * user can see how much of the turn's tool budget has been burned (issue #767). + * + * The divider is pure derived state: it is computed from the in-memory message + * list at render time and is never persisted, so scrolling back through old + * history shows the same milestones the live stream did. + */ +const val TOOL_CALL_DIVIDER_INTERVAL = 5 + +/** + * Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL] + * message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] — + * mapped to the per-turn count at that point. + * + * The counter resets at each [MessageRole.USER] message (a new turn), so + * milestones read `5, 10, 15...` within a turn and restart at `5` on the next. + */ +fun toolCallMilestones(messages: List): Map { + val milestones = mutableMapOf() + var toolCount = 0 + messages.forEachIndexed { index, message -> + when (message.role) { + MessageRole.USER -> toolCount = 0 + MessageRole.TOOL -> { + toolCount += 1 + if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { + milestones[index] = toolCount + } + } + else -> Unit + } + } + return milestones +} + +/** + * Divider label: `count/maxPerTurn` when the real budget is known (fetched from + * the backend config), bare `count` when it isn't — never a hardcoded default. + */ +fun toolCallDividerLabel( + count: Int, + maxPerTurn: Int?, +): String = + if (maxPerTurn != null && maxPerTurn > 0) { + "$count/$maxPerTurn" + } else { + count.toString() + } + +/** + * Subtle "beat counter" divider: a hairline, the per-turn count vs budget + * (e.g. `5/90`), and a hairline. Rendered between bubbles after every 5th + * tool call of the current turn. + * + * @param maxPerTurn the real per-turn tool-call budget (agent.max_turns from + * the backend config), or null when it could not be fetched — the label then + * degrades to the bare count. + */ +@Composable +fun ToolCallDivider( + count: Int, + maxPerTurn: Int? = null, + modifier: Modifier = Modifier, +) { + val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f) + val labelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = 0.5.dp, + color = lineColor, + ) + Text( + text = toolCallDividerLabel(count, maxPerTurn), + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = labelColor, + modifier = Modifier.padding(horizontal = 8.dp), + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + thickness = 0.5.dp, + color = lineColor, + ) + } +} 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 f50b3fac..41cdfd03 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 @@ -504,6 +504,7 @@ fun ChatScreen( searchMatchIndices = state.searchMatchIndices, typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, + maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, isDark = isDark, 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 7b632277..5eb96b38 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 @@ -44,7 +44,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -67,6 +70,12 @@ data class ChatUiState( val isLoading: Boolean = false, val isLoadingOlder: Boolean = false, val hasOlderMessages: Boolean = false, + /** + * Real per-turn tool-call budget (agent.max_turns) from GET /api/config. + * Null when it could not be fetched — the tool-call dividers then degrade + * to a bare count instead of a hardcoded default. + */ + val maxToolCallsPerTurn: Int? = null, /** Standalone streaming message — rendered after the main list. */ val streamingMessage: ChatMessage? = null, val errorMessage: String? = null, @@ -281,6 +290,7 @@ class ChatViewModel( init { refreshSettings() + refreshMaxToolCallsPerTurn() connectWebSocket(setLoading = false) viewModelScope.launch { @@ -1326,6 +1336,32 @@ class ChatViewModel( } } + /** + * Fetch the real per-turn tool-call budget (`agent.max_turns` — falling back + * to the legacy top-level `max_turns`) from GET /api/config so the chat's + * tool-call dividers can render `count/max` against the actual backend + * limit. Never hardcoded. Null on failure — the divider degrades to a + * bare count. + */ + private fun refreshMaxToolCallsPerTurn() { + viewModelScope.launch(Dispatchers.IO) { + try { + val response = ApiClient.hermesApi.getConfig() + if (!response.isSuccessful) return@launch + val config = response.body() ?: return@launch + val agent = config["agent"] as? JsonObject + val max = + (agent?.get("max_turns")?.jsonPrimitive?.intOrNull) + ?: config["max_turns"]?.jsonPrimitive?.intOrNull + if (max != null && max > 0) { + _uiState.update { it.copy(maxToolCallsPerTurn = max) } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch max tool calls per turn", e) + } + } + } + @Suppress("UNCHECKED_CAST") private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? = try { 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 0d1bca38..75110710 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +23,8 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.ToolCallDivider +import com.m57.hermescontrol.ui.chat.toolCallMilestones import com.m57.hermescontrol.ui.common.EmptyState /** @@ -39,6 +42,7 @@ fun ChatMessageList( searchMatchIndices: List, typingEffectEnabled: Boolean, typingEffectDelayMs: Int, + maxToolCallsPerTurn: Int? = null, isLoading: Boolean, isLoadingOlder: Boolean, isDark: Boolean, @@ -62,6 +66,7 @@ fun ChatMessageList( ) } } else { + val toolMilestones = toolCallMilestones(messages) LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -90,27 +95,33 @@ fun ChatMessageList( val isLastMessage = index == messages.lastIndex val isAssistant = message.role == MessageRole.ASSISTANT - if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && - lastAnimatedMessageId != message.id - ) { - StreamingBubbleWithTypingEffect( - streaming = message, - typingDelayMs = typingEffectDelayMs, - isDark = isDark, - onAnimationComplete = { - onLastAnimatedMessageIdChange(message.id) - }, - ) - } else { - ChatBubble( - message = message, - isDarkTheme = isDark, - searchQuery = if (isSearchActive) searchQuery else "", - isCurrentMatch = isCurrentMatch, - onRespondApproval = viewModel::respondToApproval, - onOpenAttachment = viewModel::openAttachment, - onImageClick = onImageClick, - ) + Column { + if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && + lastAnimatedMessageId != message.id + ) { + StreamingBubbleWithTypingEffect( + streaming = message, + typingDelayMs = typingEffectDelayMs, + isDark = isDark, + onAnimationComplete = { + onLastAnimatedMessageIdChange(message.id) + }, + ) + } else { + ChatBubble( + message = message, + isDarkTheme = isDark, + searchQuery = if (isSearchActive) searchQuery else "", + isCurrentMatch = isCurrentMatch, + onRespondApproval = viewModel::respondToApproval, + onOpenAttachment = viewModel::openAttachment, + onImageClick = onImageClick, + ) + } + // Subtle beat counter after every 5th tool call (issue #767). + toolMilestones[index]?.let { count -> + ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) + } } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt new file mode 100644 index 00000000..ba38c791 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt @@ -0,0 +1,97 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatListEntriesTest { + private fun toolMessage() = ChatMessage(role = MessageRole.TOOL, content = "") + + private fun textMessage() = ChatMessage(role = MessageRole.ASSISTANT, content = "hello") + + private fun userMessage() = ChatMessage(role = MessageRole.USER, content = "hi") + + @Test + fun emptyListHasNoMilestones() { + assertTrue(toolCallMilestones(emptyList()).isEmpty()) + } + + @Test + fun noToolMessagesHasNoMilestones() { + val messages = List(8) { textMessage() } + assertTrue(toolCallMilestones(messages).isEmpty()) + } + + @Test + fun fewerThanFiveToolCallsHasNoMilestones() { + val messages = List(4) { toolMessage() } + assertTrue(toolCallMilestones(messages).isEmpty()) + } + + @Test + fun fifthToolCallIsAMilestone() { + val messages = List(5) { toolMessage() } + assertEquals(mapOf(4 to 5), toolCallMilestones(messages)) + } + + @Test + fun milestoneAtEveryFifthToolCallWithinTurn() { + val messages = List(12) { toolMessage() } + assertEquals(mapOf(4 to 5, 9 to 10), toolCallMilestones(messages)) + } + + @Test + fun counterResetsAtEachUserMessage() { + val messages = + listOf( + userMessage(), // index 0 — turn 1 starts + toolMessage(), // 1 + toolMessage(), // 2 + toolMessage(), // 3 + toolMessage(), // 4 + toolMessage(), // 5 ← 5th tool call of turn 1 + userMessage(), // 6 — turn 2 starts, counter resets + toolMessage(), // 7 + toolMessage(), // 8 + toolMessage(), // 9 + toolMessage(), // 10 + toolMessage(), // 11 ← 5th tool call of turn 2 (not 10) + ) + assertEquals(mapOf(5 to 5, 11 to 5), toolCallMilestones(messages)) + } + + @Test + fun nonToolMessagesDoNotCountAndShiftIndices() { + val messages = + listOf( + textMessage(), // index 0 + toolMessage(), // 1 + toolMessage(), // 2 + textMessage(), // 3 + toolMessage(), // 4 + toolMessage(), // 5 + textMessage(), // 6 + toolMessage(), // 7 ← 5th tool call + ) + assertEquals(mapOf(7 to 5), toolCallMilestones(messages)) + } + + @Test + fun toolMessagesWithoutToolNameStillCount() { + val messages = List(5) { ChatMessage(role = MessageRole.TOOL, content = "", toolName = null) } + assertEquals(mapOf(4 to 5), toolCallMilestones(messages)) + } + + @Test + fun labelIncludesRealMaxWhenKnown() { + assertEquals("5/90", toolCallDividerLabel(count = 5, maxPerTurn = 90)) + assertEquals("10/90", toolCallDividerLabel(count = 10, maxPerTurn = 90)) + } + + @Test + fun labelDegradesToBareCountWhenMaxUnknown() { + assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = null)) + assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = 0)) + assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = -1)) + } +}