Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ ccstatusline --version

- **Tokens Input** / **Tokens Output** / **Tokens Cached** / **Tokens Total** - Show current-session token counts. Input/output prefer cumulative transcript metrics and fall back to `context_window.total_input_tokens` / `context_window.total_output_tokens` when transcript metrics are unavailable; cached/total use transcript metrics.
- **Cache Hit Rate** / **Cache Read** / **Cache Write** - Show prompt-cache efficiency. Cache Hit Rate uses cache reads divided by cache reads plus cache writes; Cache Read and Cache Write include each value's share of prompt context. They default to the latest turn from `context_window.current_usage`, can switch to cumulative session totals, and can hide when empty.
- **Cache Timer** - Estimate time remaining before the current prompt-cache entry expires. It shows `HOT` while a main-chain turn is active, then counts down from the latest assistant request with cache activity and becomes `COLD` just before expiry. Interrupts and local slash commands such as `/cost` end the working state without refreshing that cache anchor. The default TTL is 5 minutes; it can switch to 1 hour, hide when no cache anchor is available, and customize the glyph for each state. Because Claude Code transcripts expose cache token activity rather than the actual expiry timestamp, the countdown is best effort.
- **Cache Timer** - Show time remaining before the current prompt-cache entry expires. When Claude Code provides usable `prompt_cache` data, the widget counts down to `expires_at` or shows `COLD` when `warm` is false. The reported `ttl` (`5m` or `1h`) controls the fresh/draining/urgent glyph thresholds. This reported state takes precedence even during a turn, so the Working glyph (`πŸ”₯ HOT`) is used only in the transcript fallback. Without usable reported state (including `caching_observed: false`), the widget shows `HOT` while a main-chain turn is active, then estimates expiry from the latest assistant request with cache activity. Interrupts and local slash commands such as `/cost` end that working state without refreshing the cache anchor. The `t` setting selects a 5-minute or 1-hour TTL for this fallback countdown; it also supplies glyph thresholds when a reported expiry has no recognized TTL. Each state's glyph can be customized, and the widget can hide when neither reported state nor a transcript cache anchor is available.
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar. The window size is taken from Claude Code's reported `context_window.context_window_size` when present, then from a model-name hint (e.g. a `[1m]` suffix), and finally from a fixed fallback. Set `CCSTATUSLINE_CONTEXT_SIZE_FALLBACK` to a positive integer to override that last-resort fallback (defaults to `200000`) β€” useful when an older Claude Code does not report the window size for a 1M-context model, so the bar would otherwise read against 200k. Immediately after `/compact`, transcript fallback uses the latest `compact_boundary.postTokens` value until a new main-chain turn reports the current size, so the widgets do not retain the pre-compaction context.
- **Compaction Counter** - Show how many context compactions have been detected in the current session by scanning transcript compaction markers. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero. Two optional, independent per-item add-ons toggle extra detail: a trigger split (`↻ 3 (2 auto, 1 manual)`; a compaction whose trigger is missing or unrecognized is bucketed as `unknown`) and tokens reclaimed (`↻ 3 ↓887.0k`, each compaction's `preTokens - postTokens` floored at 0 and summed, shown only when greater than 0 β€” so very old transcripts predating the `postTokens` field display nothing). Its value selector can instead render the total count, one trigger count (`auto`, `manual`, or `unknown`), or reclaimed tokens as a standalone value; hide-when-zero applies to the selected value.
Expand Down Expand Up @@ -318,7 +318,7 @@ Widget-specific shortcuts:
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
- **Compaction Counter**: `v` cycle value (count/auto/manual/unknown/reclaimed), `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope
- **Cache Timer**: `t` cycle 5-minute/1-hour TTL, `g` customize the working/fresh/draining/urgent/cold glyphs
- **Cache Timer**: `t` cycle the fallback 5-minute/1-hour TTL (reported `5m`/`1h` takes precedence), `g` customize the working/fresh/draining/urgent/cold glyphs; Working is used only in the transcript fallback
- **Sandbox Status**: `f` cycle glyph/text/word format, `n` toggle Nerd Font lock icons in glyph mode
- **Claude Status**: `h` toggle the 48-hour incident-history strip, `l` toggle the link to the status page
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
Expand Down Expand Up @@ -361,7 +361,7 @@ Supported states by widget family:
- **Skills**: `empty` hides the widget before any skill is used
- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable
- **Session / Weekly / Weekly Sonnet / Weekly Opus / Weekly Fable Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable
- **Cache widgets** (`Cache Hit Rate`, `Cache Read`, `Cache Write`, `Cache Timer`): `empty` hides the widget when there is no cache activity, and on Cache Timer when no cache anchor is available
- **Cache widgets** (`Cache Hit Rate`, `Cache Read`, `Cache Write`, `Cache Timer`): `empty` hides the widget when there is no cache activity, and on Cache Timer when neither usable reported cache state nor a transcript cache anchor is available
- **Custom Text / Custom Symbol**: `merge-target-hidden` hides the item when the widget it is merged with renders nothing, so icon prefixes/suffixes disappear together with their widget

In `settings.json`, the enabled states are stored as a comma-separated list in
Expand Down
11 changes: 11 additions & 0 deletions src/types/StatusJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export const StatusJSONSchema = z.looseObject({
original_cwd: z.string().optional(),
original_branch: z.string().optional()
}).nullable().optional(),
// Claude Code reports the live prompt-cache state, including the real
// expiry timestamp, so consumers do not have to infer it from transcript
// timings. Absent on older Claude Code versions.
prompt_cache: z.object({
warm: z.boolean().nullable().optional(),
caching_observed: z.boolean().nullable().optional(),
ttl: z.string().nullable().optional(),
expires_at: CoercedNumberSchema.nullable().optional(), // Unix epoch seconds
hit_ratio: CoercedNumberSchema.nullable().optional(),
recache_tokens_if_cold: CoercedNumberSchema.nullable().optional()
}).nullable().optional(),
rate_limits: z.object({
five_hour: RateLimitPeriodSchema.optional(),
seven_day: RateLimitPeriodSchema.optional(),
Expand Down
26 changes: 26 additions & 0 deletions src/types/__tests__/StatusJSON.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ import {
import { StatusJSONSchema } from '../StatusJSON';

describe('StatusJSONSchema numeric coercion', () => {
it('coerces prompt cache numeric fields while preserving the reported state', () => {
const result = StatusJSONSchema.parse({
prompt_cache: {
warm: true,
caching_observed: true,
ttl: '1h',
expires_at: '1800000300',
hit_ratio: '0.97',
recache_tokens_if_cold: '200000'
}
});

expect(result.prompt_cache).toEqual({
warm: true,
caching_observed: true,
ttl: '1h',
expires_at: 1800000300,
hit_ratio: 0.97,
recache_tokens_if_cold: 200000
});
});

it.each([undefined, null, {}, { warm: null, caching_observed: null, expires_at: null }])('accepts optional prompt cache data: %j', (prompt_cache) => {
expect(StatusJSONSchema.parse({ prompt_cache }).prompt_cache).toEqual(prompt_cache);
});

it('coerces numeric strings to numbers', () => {
const result = StatusJSONSchema.safeParse({
cost: {
Expand Down
44 changes: 41 additions & 3 deletions src/widgets/CacheTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ const LABEL = 'Cache: ';

// Anthropic's ephemeral prompt cache defaults to a 5-minute TTL, but Claude Code
// also writes 1-hour breakpoints (cache_control ttl: "1h") for the stable prefix.
// The expiry itself is never exposed (the transcript only records token counts),
// so this is a best-effort countdown from the last turn; the TTL is configurable
// to match whichever tier the user cares about.
// Recent Claude Code reports the real expiry in status JSON as
// prompt_cache.expires_at, which is used when present. The transcript-derived
// countdown below stays as the fallback for older versions, where the expiry is
// not exposed and the configured TTL is the only thing to count down from.
const TTL_METADATA_KEY = 'ttlSeconds';
const DEFAULT_TTL_SECONDS = 300;
const TTL_OPTIONS = [300, 3600] as const; // 5 minutes, 1 hour
Expand Down Expand Up @@ -240,6 +241,36 @@ function getRemainingSeconds(lastAssistant: Date, ttlSeconds: number): number {
return ttlSeconds - SAFETY_MARGIN - elapsedSeconds;
}

/**
* Seconds left according to Claude Code's own prompt_cache.expires_at, or null
* when the field is absent (older Claude Code) or the session has not cached
* anything yet. Reported expiry beats any countdown inferred from timestamps:
* it survives a resumed session, and it is right even when the last turn read a
* cache entry written much earlier.
*/
function getReportedRemainingSeconds(context: RenderContext): number | null {
const cache = context.data?.prompt_cache;
if (!cache || cache.caching_observed === false) {
return null;
}
if (cache.warm === false) {
return 0;
}
const expiresAt = cache.expires_at;
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
return null;
}
return expiresAt - Date.now() / 1000;
}

function getReportedTtlSeconds(context: RenderContext): number | null {
switch (context.data?.prompt_cache?.ttl) {
case '5m': return 300;
case '1h': return 3600;
default: return null;
}
}

function formatCountdown(remaining: number): string {
if (remaining <= 0) {
return 'COLD';
Expand Down Expand Up @@ -308,6 +339,13 @@ export class CacheTimerWidget implements Widget {
return formatRawOrLabeledValue(item, this.getLabelPrefix(), withGlyph(getSlotSymbol(item, FRESH_SLOT), '4:52'));
}

const reportedRemaining = getReportedRemainingSeconds(context);
if (reportedRemaining !== null) {
const ttlSeconds = getReportedTtlSeconds(context) ?? getTtlSeconds(item);
const glyph = getStateSymbol(item, reportedRemaining, ttlSeconds);
return formatRawOrLabeledValue(item, this.getLabelPrefix(), withGlyph(glyph, formatCountdown(reportedRemaining)));
}

const transcriptPath = context.data?.transcript_path;
if (!transcriptPath) {
return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a');
Expand Down
69 changes: 68 additions & 1 deletion src/widgets/__tests__/CacheTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
beforeEach,
describe,
expect,
it
it,
vi
} from 'vitest';

import type { RenderContext } from '../../types';
Expand Down Expand Up @@ -45,6 +46,7 @@ describe('CacheTimer widget', () => {
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

Expand Down Expand Up @@ -84,6 +86,71 @@ describe('CacheTimer widget', () => {
expect(widget.render(item(hidden), context, DEFAULT_SETTINGS)).toBe('Cache: πŸ”₯ HOT');
});

it('uses reported expiry instead of a resumed transcript and the configured TTL', () => {
const now = 1800000000000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const context = transcriptContext([assistant(10800), pendingUser]);
context.data = { ...context.data, prompt_cache: { warm: true, caching_observed: true, ttl: '1h', expires_at: now / 1000 + 2100 } };

expect(new CacheTimerWidget().render(item(), context, DEFAULT_SETTINGS)).toBe('Cache: 🟒 35:00');
});

it.each([
{ ttl: '1h', configured: '300', remaining: 600, expected: 'Cache: πŸ”΄ 10:00' },
{ ttl: '1h', configured: '300', remaining: 1500, expected: 'Cache: 🟑 25:00' },
{ ttl: '5m', configured: '3600', remaining: 240, expected: 'Cache: 🟒 4:00' }
])('uses reported $ttl instead of configured $configured seconds for the glyph', ({ ttl, configured, remaining, expected }) => {
const now = 1800000000000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const context: RenderContext = { data: { prompt_cache: { ttl, expires_at: now / 1000 + remaining } } };

expect(new CacheTimerWidget().render(item({ metadata: { ttlSeconds: configured } }), context, DEFAULT_SETTINGS)).toBe(expected);
});

it.each([undefined, null, '', 'unknown'])('falls back to configured glyph thresholds for an unavailable reported TTL: %j', (ttl) => {
const now = 1800000000000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const context: RenderContext = { data: { prompt_cache: { ttl, expires_at: now / 1000 + 240 } } };
const widget = new CacheTimerWidget();

expect(widget.render(item(), context, DEFAULT_SETTINGS)).toBe('Cache: 🟒 4:00');
expect(widget.render(item({ metadata: { ttlSeconds: '3600' } }), context, DEFAULT_SETTINGS)).toBe('Cache: πŸ”΄ 4:00');
});

it('renders reported expiry without a transcript and preserves raw values and glyph overrides', () => {
const now = 1800000000000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const context: RenderContext = { data: { prompt_cache: { expires_at: now / 1000 + 240 } } };

expect(new CacheTimerWidget().render(item({ rawValue: true }), context, DEFAULT_SETTINGS)).toBe('🟒 4:00');
expect(new CacheTimerWidget().render(item({ rawValue: true, metadata: { symbolFresh: '+' } }), context, DEFAULT_SETTINGS)).toBe('+ 4:00');
});

it.each([
{ warm: false },
{ warm: false, expires_at: 1800000300 },
{ warm: true, expires_at: 1799999999 }
])('renders a reported cold cache before checking the transcript: %j', (prompt_cache) => {
vi.spyOn(Date, 'now').mockReturnValue(1800000000000);
const context: RenderContext = { data: { prompt_cache } };

expect(new CacheTimerWidget().render(item(hidden), context, DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
});

it.each([
null,
{},
{ caching_observed: false, warm: false, expires_at: 0 },
{ expires_at: null },
{ expires_at: Number.NaN },
{ expires_at: Number.POSITIVE_INFINITY }
])('falls back to the transcript when reported cache state is unavailable: %j', (prompt_cache) => {
const context = transcriptContext([pendingUser]);
context.data = { ...context.data, prompt_cache };

expect(new CacheTimerWidget().render(item(), context, DEFAULT_SETTINGS)).toBe('Cache: πŸ”₯ HOT');
});

const buckets = [
{ label: 'fresh', elapsed: 10, icon: '🟒' },
{ label: 'draining', elapsed: 180, icon: '🟑' },
Expand Down
Loading