feat(chat): subtle tool-call divider every 5 calls per turn (#769)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-02 15:22:29 +03:00
1 parent 1e8c3629f0
commit ec72ed4733
5 files changed
+276 -21

No files matched your search

@@ -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<ChatMessage>): Map<Int, Int> {
val milestones = mutableMapOf<Int, Int>()
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,
)
}
}
@@ -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,
@@ -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 {
@@ -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<Int>,
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)
}
}
}