mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
co-authored by
Andrey Bezborodov
parent
a32781537f
commit
626c4873ab
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
/** 0–100, 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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user