Add used/full context meter to chat screen (#717)

* Adopt /api/health REST route (issue #713)

Cheap liveness probe on the connection screen: GET /api/health surfaces
backend version + auth mode (Token vs Gated/OAuth) without the heavier
/api/status payload. Added HermesApiService.getHealth + HealthStatus model;
ConnectViewModel.loadHealth probes on open (best-effort, silent on failure);
ConnectScreen shows a read-only health card when reachable.

Verified: ktlint clean, assembleDebug BUILD SUCCESSFUL locally.

* Add used/full context meter to chat screen

Shows session prompt tokens vs model context window above the composer,
polled every 5s. Color-coded bar warns at 70%/90%.

* fix(chat): fix unit tests for context usage meter

Format >=100k tokens cleanly in ContextUsageChip and stub AuthManager profile ID in ChatViewModelTest.

* Fix context meter: source used tokens from input_tokens

Gateway /api/sessions/{id} has no last_prompt_tokens column; use
input_tokens instead. Show meter as soon as full context is known.

* Add tappable context breakdown sheet

Chip opens a bottom sheet with input/output/cache/read/write/reasoning
token rows + message count. Source breakdown from /api/sessions/{id}.

* Refresh context meter on streaming complete

Trigger fetchContextUsage immediately when a message finishes streaming
instead of waiting for the 5s session-sync poll.

* Fix context meter: source used tokens from live WS session.info

The REST /api/sessions/{id} endpoint reads the DB input_tokens column,
which is stale/0 for active sessions — that's why the meter was stuck at 0.

Switch the meter to the live values the gateway pushes over the WS
session.info payload after each turn:
- used = last_prompt_tokens (real used context for the turn)
- full = context_length (model-accurate window; keep /api/model/info as a
  fallback before the first turn completes)

fetchContextUsage now only tops up the denominator from /api/model/info and
no longer overwrites the live WS value with the stale REST input_tokens.
The 5s poll remains as a fallback.

* Revert "Fix context meter: source used tokens from live WS session.info"

This reverts commit 932393fd80993d57e66fe93552a949460d646674.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-26 01:52:22 +03:00
1 parent c181d1d593
commit 0180fb12c2
15 files changed
+641 -2

No files matched your search

@@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -78,6 +79,8 @@ import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
@@ -110,7 +113,7 @@ private const val SESSION_SYNC_INTERVAL_MS = 5_000L
* this entry point handles only state hoisting, scaffold wiring, and the
* remembered launchers that need to be activity-scoped.
*/
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
modifier: Modifier = Modifier,
@@ -125,12 +128,14 @@ fun ChatScreen(
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
var showContextSheet by remember { mutableStateOf(false) }
// Periodic session sync while connected.
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
delay(SESSION_SYNC_INTERVAL_MS)
viewModel.syncCurrentSession()
viewModel.fetchContextUsage()
}
}
@@ -512,6 +517,20 @@ fun ChatScreen(
}
}
// Context-window meter (used / full) — sits above the composer so it
// stays visible while the session is active, grouped with the model
// it belongs to without crowding the title or the control row.
ContextUsageChip(
usedTokens = state.usedContextTokens,
fullTokens = state.fullContextTokens,
onClick =
if (state.contextBreakdown != null) {
{ showContextSheet = true }
} else {
null
},
)
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -610,5 +629,13 @@ fun ChatScreen(
onDismiss = { viewModel.closeModelPicker() },
)
}
if (showContextSheet && state.contextBreakdown != null) {
ContextDetailSheet(
breakdown = state.contextBreakdown!!,
fullTokens = state.fullContextTokens,
onDismiss = { showContextSheet = false },
)
}
}
}
@@ -92,6 +92,14 @@ data class ChatUiState(
val currentSessionModel: String? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Context-window meter (issue #XXX): tokens currently used by the session
// prompt (numerator) and the active model's full context window (denominator).
// Both null until the first successful fetch.
val usedContextTokens: Long? = null,
val fullContextTokens: Long? = null,
// Detailed token breakdown for the context meter's detail sheet (null until
// the first successful session-detail fetch).
val contextBreakdown: ContextBreakdown? = null,
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears
@@ -142,6 +150,21 @@ data class SecretPromptUi(
val sessionId: String?,
)
/**
* Token breakdown backing the context meter's detail sheet. All values are
* token counts sourced from `GET /api/sessions/{id}` (`input_tokens`,
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
* `message_count`) — verified present on the live gateway's `sessions` table.
*/
data class ContextBreakdown(
val inputTokens: Long,
val outputTokens: Long,
val cacheReadTokens: Long,
val cacheWriteTokens: Long,
val reasoningTokens: Long,
val messageCount: Int,
)
class ChatViewModel(
application: Application,
private val startCleanup: Boolean,
@@ -386,6 +409,12 @@ class ChatViewModel(
is ReducerEffect.RefreshSessions -> {
loadSessions()
}
is ReducerEffect.RefreshContextUsage -> {
// Streaming finished — refresh the context meter now rather
// than waiting up to 5s for the next session-sync poll.
viewModelScope.launch { fetchContextUsage() }
}
}
}
@@ -1349,6 +1378,8 @@ class ChatViewModel(
currentSessionModel = "$provider/$model",
)
}
// Model switch changes the context-window denominator — refetch it.
fetchContextUsage()
handleSlashCommand("/model $model --provider $provider --session")
}
@@ -1549,6 +1580,65 @@ class ChatViewModel(
}
}
/**
* Refresh the context-window meter for the current session.
*
* Numerator (`usedContextTokens`) comes from the session record's
* `last_prompt_tokens` (`/api/sessions/{id}`, backend
* `gateway/session.py`). Denominator (`fullContextTokens`) comes from the
* active model's `effective_context_length` (`/api/model/info`, PUBLIC).
*
* Both calls are independent and best-effort: a failure on one must not
* wipe the other's already-shown value, and neither blocks the chat. The
* two fetches are launched separately so a slow/erroring one can't starve
* the other. Polled from [syncCurrentSession] via the 5s loop and re-fired
* on model switch (the denominator changes).
*/
fun fetchContextUsage() {
val sessionId = _uiState.value.currentSessionId ?: return
val profile = AuthManager.getSelectedProfileId()
viewModelScope.launch(Dispatchers.IO) {
// Denominator: full context window (cheap, public, rarely changes).
val fullResult =
safeApiCall { ApiClient.hermesApi.getModelInfo() }
if (fullResult is NetworkResult.Success) {
val full =
fullResult.data.effective_context_length
?: fullResult.data.auto_context_length
?: fullResult.data.config_context_length
if (full != null && full > 0L) {
_uiState.update { it.copy(fullContextTokens = full) }
}
}
// Numerator: used context for THIS session. The gateway's sessions
// table persists `input_tokens` (cumulative prompt tokens) — there is
// NO `last_prompt_tokens` column on the REST response, so we use
// `input_tokens` as the used-context numerator.
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
if (usedResult is NetworkResult.Success) {
val d = usedResult.data
val used = d.input_tokens
if (used != null) {
_uiState.update {
it.copy(
usedContextTokens = used,
contextBreakdown =
ContextBreakdown(
inputTokens = used,
outputTokens = d.output_tokens ?: 0L,
cacheReadTokens = d.cache_read_tokens ?: 0L,
cacheWriteTokens = d.cache_write_tokens ?: 0L,
reasoningTokens = d.reasoning_tokens ?: 0L,
messageCount = d.message_count ?: 0,
),
)
}
}
}
}
}
private suspend fun fetchServerMessageCount(sessionId: String): Int {
val known =
_uiState.value.sessions
@@ -252,6 +252,9 @@ object ChatWsEventReducer {
effects.add(ReducerEffect.PersistMessage(msg, sid))
}
effects.add(ReducerEffect.RefreshSessions)
// Refresh the context meter immediately on completion instead of
// waiting for the next 5s session-sync poll.
effects.add(ReducerEffect.RefreshContextUsage)
return ReducerResult(
state =
state.copy(
@@ -664,4 +667,6 @@ sealed class ReducerEffect {
data object LoadSessions : ReducerEffect()
data object RefreshSessions : ReducerEffect()
data object RefreshContextUsage : ReducerEffect()
}
@@ -0,0 +1,162 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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 com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ContextBreakdown
import kotlin.math.min
/**
* Detail sheet shown when the context meter chip is tapped. Lists the session's
* token breakdown (input / output / cache read / cache write / reasoning) and
* the message count, plus a prominent used / full bar.
*
* `fullTokens` is the model context window (denominator); `breakdown` carries
* the per-category counts from `GET /api/sessions/{id}`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContextDetailSheet(
breakdown: ContextBreakdown,
fullTokens: Long?,
onDismiss: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
val full = fullTokens ?: 0L
val used = breakdown.inputTokens
val fraction = if (full > 0L) min(1f, used.toFloat() / full.toFloat()) else 0f
val pct = (fraction * 100).toInt()
val statusColors = LocalHermesStatusColors.current
val barColor =
when {
pct >= 90 -> statusColors.error
pct >= 70 -> statusColors.warning
else -> MaterialTheme.colorScheme.primary
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Context window",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
// Big used / full header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Text(
text = formatTokens(used),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = barColor,
)
Text(
text = "of ${formatTokens(full)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Progress bar
Box(
modifier =
Modifier
.fillMaxWidth()
.height(8.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(4.dp),
),
) {
Box(
modifier =
Modifier
.fillMaxWidth(fraction)
.height(8.dp)
.background(color = barColor, shape = RoundedCornerShape(4.dp)),
)
}
Text(
text = "$pct% of context window used",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Breakdown rows
ContextRow(label = "Prompt (input)", value = breakdown.inputTokens)
ContextRow(label = "Completion (output)", value = breakdown.outputTokens)
ContextRow(label = "Cache read", value = breakdown.cacheReadTokens)
ContextRow(label = "Cache write", value = breakdown.cacheWriteTokens)
ContextRow(label = "Reasoning", value = breakdown.reasoningTokens)
ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true)
Text(
text = "Prompt tokens reflect total context used by this session so far.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
}
@Composable
private fun ContextRow(
label: String,
value: Long,
isCount: Boolean = false,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = if (isCount) value.toString() else formatTokens(value),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,147 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import kotlin.math.min
/**
* Compact "used / full context" meter for the chat screen.
*
* Renders `<used> / <full> context` (e.g. `12.3k / 262k`) plus a color-coded
* progress bar. The bar turns amber past 70% and red past 90% of the context
* window so the user sees compression pressure building before a turn silently
* compacts.
*
* The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and
* is always available. The numerator (`usedTokens`) comes from
* `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent
* on first render. The chip shows as soon as `fullTokens` is known; when
* `usedTokens` is still null it renders `— / <full>` (unknown used) rather than
* hiding entirely, so a missing gated fetch never blanks the meter.
*
* @param usedTokens tokens currently in the session prompt (numerator), or null
* @param fullTokens the active model's full context window (denominator)
*/
@Composable
fun ContextUsageChip(
usedTokens: Long?,
fullTokens: Long?,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val full = fullTokens
if (full == null || full <= 0L) return
val used = usedTokens ?: 0L
val fraction = min(1f, used.toFloat() / full.toFloat())
val pct = (fraction * 100).toInt()
val statusColors = LocalHermesStatusColors.current
val barColor =
when {
pct >= 90 -> statusColors.error
pct >= 70 -> statusColors.warning
else -> MaterialTheme.colorScheme.primary
}
Column(
modifier =
modifier
.fillMaxWidth()
.then(
if (onClick != null) {
Modifier
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 4.dp)
} else {
Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
},
),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
if (usedTokens != null) {
"${formatTokens(used)} / ${formatTokens(full)} context"
} else {
"— / ${formatTokens(full)} context"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "$pct%",
style = MaterialTheme.typography.labelSmall,
color = barColor,
maxLines = 1,
)
}
Box(
modifier =
Modifier
.fillMaxWidth()
.height(3.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(2.dp),
),
) {
Box(
modifier =
Modifier
.fillMaxWidth(fraction)
.height(3.dp)
.background(
color = barColor,
shape = RoundedCornerShape(2.dp),
),
)
}
}
}
/**
* Compact token formatting: 1_500 → "1.5k", 262_144 → "262k", 950 → "950".
* Keeps the chip narrow so it fits above the composer on small screens.
*/
internal fun formatTokens(tokens: Long): String =
when {
tokens >= 1_000_000 -> {
"${tokens / 1_000_000}M"
}
tokens >= 100_000 -> {
"${tokens / 1000}k"
}
tokens >= 1_000 -> {
val k = tokens / 1000.0
if (k % 1.0 == 0.0) "${k.toInt()}k" else String.format(java.util.Locale.US, "%.1fk", k)
}
else -> {
tokens.toString()
}
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -316,6 +317,43 @@ fun ConnectScreen(
)
}
}
// Backend health read-out (issue #713): cheap /api/health probe.
// Shows version + auth mode when reachable; nothing when null.
state.health?.let { health ->
val authMode =
if (health.authRequired == true) {
stringResource(R.string.connect_health_auth_gated)
} else {
stringResource(R.string.connect_health_auth_token)
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.connect_health_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text =
stringResource(
R.string.connect_health_body,
health.version ?: "?",
authMode,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkError
@@ -33,6 +34,7 @@ data class ConnectUiState(
val saveProfile: Boolean = false,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfile: ConnectionProfile? = null,
val health: HealthStatus? = null,
)
class ConnectViewModel(
@@ -60,6 +62,29 @@ class ConnectViewModel(
profileName = selectedProfile?.name ?: "",
)
}
loadHealth()
}
/**
* Cheap liveness probe (issue #713): GET /api/health against the current
* URL/token. Surfaces backend version + auth mode on the connection screen
* without the heavier /api/status payload. Best-effort: failures are silent
* (health stays null and the screen simply shows no read-out).
*/
fun loadHealth() {
val state = _uiState.value
val endpoint = runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
if (endpoint == null || state.token.isBlank()) return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
safeApiCall { tempApi.getHealth() }
}
if (result is NetworkResult.Success) {
_uiState.update { it.copy(health = result.data) }
}
}
}
fun onProfileNameChange(value: String) {