diff --git a/packages/zcode-tui/src/context-status-view.ts b/packages/zcode-tui/src/context-status-view.ts index cd76e98..e824cd0 100644 --- a/packages/zcode-tui/src/context-status-view.ts +++ b/packages/zcode-tui/src/context-status-view.ts @@ -11,7 +11,7 @@ import type { RuntimeProjectionSnapshot } from "./runtime-projection.ts"; import type { ContextCacheTrend, ContextCacheTurn } from "./context-cache.ts"; -import type { SessionMetrics } from "./session-status.ts"; +import { contextUsedPercent, type SessionMetrics } from "./session-status.ts"; import type { ZCodeTheme } from "./theme.ts"; const contextLabels: Record = { @@ -156,7 +156,10 @@ export class ContextDetailView implements Component { const columns = totalChars > 0 ? Math.max(1, Math.round(item.chars / totalChars * barWidth)) : 0; return contextStyle(item.source, this.theme)("█".repeat(columns)); }).join(""); - const usedPercent = Math.max(0, Math.round(this.usage.used / this.usage.size * 100)); + const usedPercent = contextUsedPercent({ + contextUsed: this.usage.used, + contextWindow: this.usage.size + }) ?? 0; const remaining = Math.max(0, this.usage.size - this.usage.used); const usageStyle = usedPercent >= 90 ? this.theme.error : usedPercent >= 70 ? this.theme.warning : (text: string) => text; const lines = [ @@ -306,7 +309,7 @@ export class StatusDetailView implements Component { ? [projection.lastError.code, projection.lastError.message].filter(Boolean).join(" · ") : undefined], ["Turns", String(metrics.turnCount ?? projection?.turnCount ?? 0)], - ["Tokens", metrics.totalTokens !== undefined ? formatTokens(metrics.totalTokens) : undefined], + ["Session tokens", metrics.totalTokens !== undefined ? formatTokens(metrics.totalTokens) : undefined], ["Requests", metrics.modelRequestCount !== undefined ? `${metrics.modelRequestCount}${metrics.modelErrorCount ? ` · ${metrics.modelErrorCount} errors` : ""}` : undefined], diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index aeee016..1ec3d40 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -162,6 +162,7 @@ import { import { contextRemainingPercent, mergeMetrics, + mergeProjectionMetrics, projectionMetrics, sessionIdFromUsage, usageMetrics, @@ -1447,7 +1448,11 @@ class ZCodeTui { } if (Array.isArray(result.modelOptions)) this.modelOptions = [...result.modelOptions]; if (Array.isArray(result.effortOptions)) this.effortOptions = [...result.effortOptions]; - this.sessionMetrics = mergeMetrics(this.sessionMetrics, projectionMetrics(result.projection)); + this.sessionMetrics = mergeProjectionMetrics( + this.sessionMetrics, + projectionMetrics(result.projection), + Boolean(this.options.readSessionUsage) + ); if (Array.isArray(result.todos)) this.todos = normalizeTodos(result.todos); if (Array.isArray(result.todoGroups)) this.todoGroups = normalizeTodoGroups(result); this.applyRuntimeProjection(normalizeRuntimeProjection(result)); @@ -3950,8 +3955,8 @@ class ZCodeTui { if (this.sessionMetrics.totalTokens !== undefined) { const tokens = formatTokens(this.sessionMetrics.totalTokens); fields.push({ - text: this.theme.muted(`${tokens} tokens`), - compactText: this.theme.muted(`${tokens} tok`), + text: this.theme.muted(`session ${tokens} tokens`), + compactText: this.theme.muted(`session ${tokens}`), priority: 20 }); } @@ -4064,12 +4069,16 @@ class ZCodeTui { this.runtimeProjection = projection; this.reconcileTurnTiming(projection); if (projection.sessionId) this.sessionId = projection.sessionId; - this.sessionMetrics = mergeMetrics(this.sessionMetrics, { - contextUsed: projection.contextUsage?.used, - contextWindow: projection.contextUsage?.size, - totalTokens: projection.totalTokenCount, - turnCount: projection.turnCount - }); + this.sessionMetrics = mergeProjectionMetrics( + this.sessionMetrics, + { + contextUsed: projection.contextUsage?.used, + contextWindow: projection.contextUsage?.size, + totalTokens: projection.totalTokenCount, + turnCount: projection.turnCount + }, + Boolean(this.options.readSessionUsage) + ); this.updateRuntimeActivity(false); } diff --git a/packages/zcode-tui/src/session-status.ts b/packages/zcode-tui/src/session-status.ts index d6fb2bb..2bfd1b4 100644 --- a/packages/zcode-tui/src/session-status.ts +++ b/packages/zcode-tui/src/session-status.ts @@ -53,10 +53,29 @@ export function sessionIdFromUsage(value: unknown): string | undefined { } export function mergeMetrics(current: SessionMetrics, update: SessionMetrics | undefined): SessionMetrics { - return update ? { ...current, ...update } : current; + if (!update) return current; + const defined = Object.fromEntries( + Object.entries(update).filter((entry): entry is [string, number] => entry[1] !== undefined) + ) as SessionMetrics; + return { ...current, ...defined }; } -export function contextRemainingPercent(metrics: SessionMetrics): number | undefined { +export function mergeProjectionMetrics( + current: SessionMetrics, + update: SessionMetrics | undefined, + hasAuthoritativeSessionUsage: boolean +): SessionMetrics { + if (!update || !hasAuthoritativeSessionUsage) return mergeMetrics(current, update); + const { totalTokens: _projectionTotal, ...contextMetrics } = update; + return mergeMetrics(current, contextMetrics); +} + +export function contextUsedPercent(metrics: SessionMetrics): number | undefined { if (metrics.contextUsed === undefined || !metrics.contextWindow) return undefined; - return Math.max(0, Math.min(100, Math.round((1 - metrics.contextUsed / metrics.contextWindow) * 100))); + return Math.max(0, Math.min(100, Math.round(metrics.contextUsed / metrics.contextWindow * 100))); +} + +export function contextRemainingPercent(metrics: SessionMetrics): number | undefined { + const used = contextUsedPercent(metrics); + return used === undefined ? undefined : 100 - used; } diff --git a/test/session-status.test.ts b/test/session-status.test.ts index 6e88f9c..93cd009 100644 --- a/test/session-status.test.ts +++ b/test/session-status.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; import { contextRemainingPercent, + contextUsedPercent, mergeMetrics, + mergeProjectionMetrics, projectionMetrics, sessionIdFromUsage, usageMetrics @@ -41,6 +43,30 @@ describe("TUI session status", () => { expect(contextRemainingPercent({ contextUsed: 200, contextWindow: 100 })).toBe(0); }); + test("keeps session-store totals authoritative over runtime projections", () => { + const usage = { totalTokens: 18_500, inputTokens: 14_000 }; + const projection = { totalTokens: 417_202, contextUsed: 32_000, contextWindow: 128_000 }; + + expect(mergeProjectionMetrics(usage, projection, true)).toEqual({ + totalTokens: 18_500, + inputTokens: 14_000, + contextUsed: 32_000, + contextWindow: 128_000 + }); + expect(mergeProjectionMetrics({}, projection, false).totalTokens).toBe(417_202); + }); + + test("does not erase metrics with absent fields and keeps used plus remaining at 100%", () => { + expect(mergeMetrics( + { totalTokens: 18_500, contextUsed: 32_000 }, + { contextWindow: 128_000, totalTokens: undefined } + )).toEqual({ totalTokens: 18_500, contextUsed: 32_000, contextWindow: 128_000 }); + + const metrics = { contextUsed: 1, contextWindow: 8 }; + expect(contextUsedPercent(metrics)).toBe(13); + expect(contextRemainingPercent(metrics)).toBe(87); + }); + test("extracts the official session identifier from usage snapshots", () => { expect(sessionIdFromUsage({ sessionId: "sess_protocol" })).toBe("sess_protocol"); expect(sessionIdFromUsage({ sessionID: "sess_store" })).toBe("sess_store");