feat(statusline): show token counts in context bar (ctx 74K/200K (37%)) (#1967)

* feat(statusline): show token counts in context bar (ctx 74K/200K (37%))

Instead of just percentage, show actual token usage and context window
size with integer K/M prefixes (uppercase, en-US locale).

- BuiltinStatusLine: added contextInputTokens + contextWindow fields
- buildBuiltinStatusSegments: formats "ctx {used}/{window} ({pct}%)"
- format.ts: added formatTokenCount() — integer, uppercase K/M
- All 18 tests pass

* fix(statusline): call getCurrentUsage once and reuse result

* fix(statusline): mark estimated token counts with ~ prefix

When getCurrentUsage() returns is_estimated:true (provider reported
all-zero usage), show "ctx ~74K/200K (37%)" instead of "ctx 74K/200K
(37%)" so built-in statusline preserves the estimate distinction
exposed by the custom-statusline contract.

* fix(openai-shim): send stream_options for non-Ollama local providers

Previously stream_options was disabled for all local URLs (127.0.0.1,
192.168.x.x, etc.), preventing llama-server and other self-hosted
OpenAI-compatible servers from returning usage in SSE streams. Now only
Ollama (localhost:11434) is excluded, since it rejects stream_options.
All other providers including llama-server receive stream_options and
their prompt_tokens/completion_tokens are correctly mapped via
buildAnthropicUsageFromRawUsage.

---------

Co-authored-by: Andrey Bezborodov <andrey@getdataflow.ru>
This commit is contained in:
Andrey Bezborodov
2026-07-15 23:58:44 +08:00
committed by GitHub
co-authored by Andrey Bezborodov
parent a32781537f
commit 626c4873ab
4 changed files with 103 additions and 23 deletions
+65 -18
View File
@@ -10,6 +10,8 @@ import {
const fullData: BuiltinStatusData = {
modelName: 'Opus 4.8',
contextUsedPercent: 37.4,
contextInputTokens: 74000,
contextWindow: 200000,
costUSD: 1.234,
rateLimit: { label: '5h', usedPercent: 42 },
}
@@ -25,7 +27,7 @@ describe('buildBuiltinStatusSegments', () => {
])
expect(segments.map(s => s.text)).toEqual([
'Opus 4.8',
'ctx 37%',
'ctx 74K/200K (37%)',
'$1.23',
'5h 42%',
])
@@ -35,6 +37,8 @@ describe('buildBuiltinStatusSegments', () => {
const segments = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: null,
contextInputTokens: null,
contextWindow: null,
})
expect(segments.find(s => s.key === 'context')).toBeUndefined()
})
@@ -58,23 +62,37 @@ describe('buildBuiltinStatusSegments', () => {
expect(at(90)).toBe('error')
})
it('shows token counts in context segment', () => {
const ctx = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: 37.4,
contextInputTokens: 74000,
contextWindow: 200000,
}).find(s => s.key === 'context')
expect(ctx?.text).toBe('ctx 74K/200K (37%)')
expect(ctx?.shortText).toBe('ctx 74K/200K')
})
it('shows sub-one-percent context usage as nonzero', () => {
const ctx = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: 0.01,
contextInputTokens: 20,
contextWindow: 200000,
}).find(s => s.key === 'context')
expect(ctx?.text).toBe('ctx 20/200K (<1%)')
})
it('colors context by the displayed rounded percentage', () => {
const at = (pct: number) =>
buildBuiltinStatusSegments({ ...fullData, contextUsedPercent: pct }).find(
s => s.key === 'context',
)
expect(at(69.6)).toMatchObject({ text: 'ctx 70%', color: 'warning' })
expect(at(89.6)).toMatchObject({ text: 'ctx 90%', color: 'error' })
})
it('shows sub-one-percent context usage as nonzero', () => {
const context = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: 0.01,
}).find(s => s.key === 'context')
expect(context?.text).toBe('ctx <1%')
expect(at(69.6)).toMatchObject({ text: 'ctx 74K/200K (70%)', color: 'warning' })
expect(at(89.6)).toMatchObject({ text: 'ctx 74K/200K (90%)', color: 'error' })
})
it('colors rate limit by usage thresholds', () => {
@@ -87,35 +105,64 @@ describe('buildBuiltinStatusSegments', () => {
expect(at(60)).toBe('warning')
expect(at(85)).toBe('error')
})
it('prefixes estimated tokens with ~ when contextIsEstimated is true', () => {
const ctx = buildBuiltinStatusSegments({
...fullData,
contextUsedPercent: 37,
contextInputTokens: 74000,
contextWindow: 200000,
contextIsEstimated: true,
}).find(s => s.key === 'context')
expect(ctx?.text).toBe('ctx ~74K/200K (37%)')
expect(ctx?.shortText).toBe('ctx ~74K/200K')
})
it('does not show ~ when contextIsEstimated is false or absent', () => {
const ctxWithFlag = buildBuiltinStatusSegments({
...fullData,
contextIsEstimated: false,
}).find(s => s.key === 'context')
const ctxWithoutFlag = buildBuiltinStatusSegments({
...fullData,
}).find(s => s.key === 'context')
expect(ctxWithFlag?.text).toBe('ctx 74K/200K (37%)')
expect(ctxWithFlag?.shortText).toBe('ctx 74K/200K')
expect(ctxWithoutFlag?.text).toBe('ctx 74K/200K (37%)')
expect(ctxWithoutFlag?.shortText).toBe('ctx 74K/200K')
})
})
describe('fitSegments', () => {
const segments = buildBuiltinStatusSegments(fullData)
// 'Opus 4.8 · ctx 37% · $1.23 · 5h 42%' = 35 cols
// 'Opus 4.8 · ctx 74K/200K (37%) · $1.23 · 5h 42%' = 46 cols
it('keeps everything when the line fits', () => {
expect(fitSegments(segments, 120)).toHaveLength(4)
})
it('degrades segments to short forms before dropping any', () => {
// 'Opus 4.8 · 37% · $1 · 5h 42%' = 28 cols — all four survive at 30
const fitted = fitSegments(segments, 30)
// Full: 'Opus 4.8 · ctx 74K/200K (37%) · $1.23 · 5h 42%' = 46 cols
// Degraded shortText: 'Opus 4.8 · ctx 74K/200K · $1 · 5h 42%' = 39 cols
const fitted = fitSegments(segments, 40)
expect(fitted.map(s => s.key)).toEqual([
'model',
'context',
'cost',
'rateLimit',
])
expect(fitted.find(s => s.key === 'context')?.text).toBe('37%')
expect(fitted.find(s => s.key === 'context')?.text).toBe('ctx 74K/200K')
expect(fitted.find(s => s.key === 'cost')?.text).toBe('$1')
})
it('marks dropped segments with a trailing ellipsis', () => {
// Too narrow for all four even degraded; hidden data must be visible as hidden
const fitted = fitSegments(segments, 22)
// Full is ~46 cols, degraded shortText is ~39 cols — try width 35
const fitted = fitSegments(segments, 35)
expect(fitted.at(-1)?.key).toBe('truncated')
expect(fitted.at(-1)?.text).toBe('…')
expect(fitted.map(s => s.key)).toContain('context')
})
it('keeps only the model at very narrow widths, skipping the marker if it will not fit', () => {
+29 -4
View File
@@ -17,6 +17,7 @@ import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
import { getRuntimeMainLoopModel, renderModelName } from '../utils/model/model.js';
import type { Theme } from '../utils/theme.js';
import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js';
import { formatTokenCount } from '../utils/format.js';
/**
* Built-in status bar shown when the user has NOT configured a custom
@@ -47,6 +48,12 @@ export type BuiltinStatusData = {
modelName: string;
/** 0100, or null before the first assistant turn. */
contextUsedPercent: number | null;
/** Current input token count (including cache), or null. */
contextInputTokens: number | null;
/** Model context window size in tokens. */
contextWindow: number | null;
/** When true, token counts are transcript-based estimates (e.g. all-zero provider response). */
contextIsEstimated?: boolean;
costUSD: number;
/** Worst rate-limit window, or null when no utilization data (API-key users). */
rateLimit: {
@@ -60,15 +67,26 @@ export function buildBuiltinStatusSegments(data: BuiltinStatusData): StatusSegme
priority: 0,
text: data.modelName
}];
if (data.contextUsedPercent !== null) {
if (data.contextUsedPercent !== null && data.contextInputTokens !== null && data.contextWindow !== null) {
const pct = data.contextUsedPercent;
const roundedPct = Math.round(pct);
const usedTokens = data.contextInputTokens;
const window = data.contextWindow;
const pctText = pct > 0 && pct < 1 ? '<1' : String(roundedPct);
// Token display: "ctx ~5K/200K (7%)" — full form
// The ~ prefix signals that input token counts are transcript-based
// estimates (e.g. provider reported all-zero usage), matching the
// public custom-statusline contract which exposes is_estimated.
const prefix = data.contextIsEstimated ? '~' : '';
const tokenText = `${prefix}${formatTokenCount(usedTokens)}/${formatTokenCount(window)}`;
const text = `ctx ${tokenText} (${pctText}%)`;
// Short form: "ctx ~5K/200K"
const shortText = `ctx ${tokenText}`;
segments.push({
key: 'context',
priority: 1,
text: `ctx ${pctText}%`,
shortText: `${pctText}%`,
text,
shortText,
// Thresholds align with the auto-compact warnings
color: roundedPct >= 90 ? 'error' : roundedPct >= 70 ? 'warning' : undefined
});
@@ -178,10 +196,17 @@ function BuiltinStatusLineInner({
exceeds200kTokens
});
const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
const contextPercentages = calculateContextPercentages(getCurrentUsage(msgs), contextWindowSize);
const currentUsage = getCurrentUsage(msgs);
const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize);
const inputTokens = currentUsage
? currentUsage.input_tokens + currentUsage.cache_creation_input_tokens + currentUsage.cache_read_input_tokens
: null;
return {
modelName: renderModelName(runtimeModel),
contextUsedPercent: contextPercentages.used,
contextInputTokens: inputTokens,
contextWindow: contextWindowSize,
contextIsEstimated: currentUsage?.is_estimated,
costUSD: getTotalCost()
};
// messagesRef is stable; lastAssistantMessageId is the messages-changed signal
+1 -1
View File
@@ -3877,7 +3877,7 @@ class OpenAIShimMessages {
body.max_completion_tokens = maxCompletionTokensValue
}
if (params.stream && !isLocalProviderUrl(request.baseUrl)) {
if (params.stream && !isLikelyOllamaEndpoint(request.baseUrl)) {
body.stream_options = { include_usage: true }
}
+8
View File
@@ -148,6 +148,14 @@ export function formatTokens(count: number): string {
return formatNumber(count).replace('.0', '')
}
/** Formats a token count as integer — no decimal fraction, uppercase K/M prefix. Always en-US locale. */
export function formatTokenCount(count: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 0,
}).format(count)
}
type RelativeTimeStyle = 'long' | 'short' | 'narrow'
type RelativeTimeOptions = {