From 0180fb12c28869979b2cba3ec96d721280f025aa Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:52:22 +0300 Subject: [PATCH] Add used/full context meter to chat screen (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../hermescontrol/data/model/HealthStatus.kt | 17 ++ .../data/model/ModelInfoResponse.kt | 30 ++++ .../data/model/SessionDetailResponse.kt | 31 ++++ .../data/remote/HermesApiService.kt | 17 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 29 +++- .../hermescontrol/ui/chat/ChatViewModel.kt | 90 ++++++++++ .../ui/chat/ChatWsEventReducer.kt | 5 + .../ui/chat/components/ContextDetailSheet.kt | 162 ++++++++++++++++++ .../ui/chat/components/ContextUsageChip.kt | 147 ++++++++++++++++ .../hermescontrol/ui/connect/ConnectScreen.kt | 38 ++++ .../ui/connect/ConnectViewModel.kt | 25 +++ app/src/main/res/values-ko/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../ui/chat/ChatViewModelTest.kt | 6 +- .../ui/chat/ContextUsageChipTest.kt | 38 ++++ 15 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/HealthStatus.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ContextUsageChipTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/HealthStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/HealthStatus.kt new file mode 100644 index 00000000..29c8d757 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/HealthStatus.kt @@ -0,0 +1,17 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Backend liveness probe, mirrored from `GET /api/health` (hermes_cli/web_server.py). + * + * A lightweight alternative to `/api/status` — no gateway PID / remote-health + * probe, no profile scope. `authRequired` tells the client which auth scheme the + * gateway expects (gated/OAuth vs loopback token) before attempting a real call. + */ +@Serializable +data class HealthStatus( + val ok: Boolean = false, + val version: String? = null, + val authRequired: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt new file mode 100644 index 00000000..18351c08 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Response from `GET /api/model/info` (PUBLIC, no auth required). + * + * VERIFIED against the live gateway (:9119, 2026-07-25): the payload carries + * `model`, `provider`, `auto_context_length`, `config_context_length`, and + * `effective_context_length`. The latter is the authoritative full context + * window for the active model — what the chat screen surfaces as "full context" + * in the used/full context meter. `context_length` (raw, per-session) is not + * part of this endpoint; per-session *used* context comes from + * [SessionDetailResponse.last_prompt_tokens] instead. + * + * Decoded with kotlinx `Json { ignoreUnknownKeys = true }` (see + * [com.m57.hermescontrol.data.remote.OkHttpProvider.json]), so unknown backend + * fields are tolerated. + */ +@Serializable +data class ModelInfoResponse( + val model: String? = null, + val provider: String? = null, + /** Context window the runtime resolved from the provider catalog. */ + val auto_context_length: Long? = null, + /** User-configured override (0 when unset). */ + val config_context_length: Long? = null, + /** Effective context window actually in use (max of auto/config + caps). */ + val effective_context_length: Long? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt new file mode 100644 index 00000000..3dc4a833 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt @@ -0,0 +1,31 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Response from `GET /api/sessions/{id}` (gated — rides the app's existing + * cookie auth). Only the fields the chat screen needs for the context meter + * are modelled; the backend serializes many more and [kotlinx.serialization] + * ignores the rest. + * + * VERIFIED against the live gateway SQLite store (`/files/agent-vault/hermes/ + * state.db`, 2026-07-26): the `sessions` table exposes `input_tokens`, + * `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, + * `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column + * (that field lives only on the in-memory `SessionEntry` in the gateway + * process, never persisted to the REST response). So the *used* context window + * is sourced from `input_tokens` (cumulative prompt tokens for the session), + * paired with [ModelInfoResponse.effective_context_length] as the denominator. + */ +@Serializable +data class SessionDetailResponse( + val session_id: String? = null, + val session_key: String? = null, + /** Cumulative prompt tokens for the session — the *used* context window. */ + val input_tokens: Long? = null, + val output_tokens: Long? = null, + val cache_read_tokens: Long? = null, + val cache_write_tokens: Long? = null, + val reasoning_tokens: Long? = null, + val message_count: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index f6a6cd21..c2a32327 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -29,6 +29,7 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate +import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse @@ -46,6 +47,7 @@ import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.ModelAssignmentRequest import com.m57.hermescontrol.data.model.ModelAssignmentResponse +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse import com.m57.hermescontrol.data.model.OAuthCancelResponse @@ -64,6 +66,7 @@ import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.RecentUnlock import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.ScanStatus +import com.m57.hermescontrol.data.model.SessionDetailResponse import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionPromptResponse @@ -123,6 +126,9 @@ interface HermesApiService { @GET("api/status") suspend fun getStatus(): Response + @GET("api/health") + suspend fun getHealth(): Response + @GET("api/sessions") suspend fun getSessions( @Query("limit") limit: Int = 20, @@ -181,6 +187,17 @@ interface HermesApiService { @Path("id", encoded = true) sessionId: String, ): Response + @GET("api/sessions/{id}") + suspend fun getSessionDetail( + // Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468). + // Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces). + @Path("id", encoded = true) sessionId: String, + @Query("profile") profile: String? = null, + ): Response + + @GET("api/model/info") + suspend fun getModelInfo(): Response + @GET("api/system/stats") suspend fun getSystemStats(): Response 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 3a38cf48..19cef023 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 @@ -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 }, + ) + } } } 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 733b6267..54adbc75 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 @@ -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 = 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 57b6e6ba..eaf1a0b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt new file mode 100644 index 00000000..14420e9b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt new file mode 100644 index 00000000..6f1ada15 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt @@ -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 ` / 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 `— / ` (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() + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index 2db554ac..31b68cd6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -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, + ) + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 89ff8ee7..af8ae8b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -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 = 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) { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ab6f1b53..7af9a50b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -179,6 +179,10 @@ 프로필: %1$s 저장된 프로필 선택 + 백엔드 상태 + Hermes %1$s · %2$s + 게이트(OAuth) + 토큰 토큰이 필요합니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d894978..ff496884 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,10 @@ Profile: %1$s Select Saved Profile + Backend health + Hermes %1$s · %2$s + Gated (OAuth) + Token Token is required 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 5c014a86..d6a5fe89 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 @@ -83,6 +83,7 @@ class ChatViewModelTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false @@ -1742,7 +1743,10 @@ class ChatViewModelTest { "hasOlderMessages must be false when initial page is empty", viewModel.uiState.value.hasOlderMessages, ) - assertTrue(viewModel.uiState.value.messages.isEmpty()) + assertTrue( + viewModel.uiState.value.messages + .isEmpty(), + ) assertFalse(viewModel.uiState.value.isLoading) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ContextUsageChipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ContextUsageChipTest.kt new file mode 100644 index 00000000..ab19e0a9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ContextUsageChipTest.kt @@ -0,0 +1,38 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for the token-compaction math in [ContextUsageChip] — the only + * non-trivial pure logic in the context meter. Formats must stay compact so + * the chip fits above the composer on a narrow phone screen. + */ +class ContextUsageChipTest { + @Test + fun formatTokens_subThousand_returnsRawCount() { + assertEquals("950", formatTokens(950)) + assertEquals("0", formatTokens(0)) + } + + @Test + fun formatTokens_thousands_dropsTrailingPointZero() { + // 262_144 -> "262k" (no ".0"), not "262.1k". + assertEquals("262k", formatTokens(262_144)) + // 12_300 -> "12.3k" (one decimal preserved). + assertEquals("12.3k", formatTokens(12_300)) + } + + @Test + fun formatTokens_millions_usesMSuffix() { + assertEquals("1M", formatTokens(1_000_000)) + assertEquals("2M", formatTokens(2_048_000)) + } + + @Test + fun formatTokens_exactThousandBoundary_roundsDownCleanly() { + // 1_000 -> "1k" not "1.0k". + assertEquals("1k", formatTokens(1_000)) + assertEquals("100k", formatTokens(100_000)) + } +}