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

@@ -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)
}
@@ -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))
}
}