Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,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. 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** - 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. The countdown renders like the reset timers (`4m 52s`, or `4m52s` in short time mode). 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 (Backspace in the glyph editor renders a state without one). Because Claude Code transcripts expose cache token activity rather than the actual expiry timestamp, the countdown is best effort.
- **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 @@ -277,7 +277,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 5-minute/1-hour TTL, `s` toggle compact time, `g` customize the working/fresh/draining/urgent/cold glyphs; Backspace in the glyph editor renders that state without one
- **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
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
Expand Down
41 changes: 34 additions & 7 deletions src/widgets/CacheTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import type {
import { CACHE_EMPTY_HIDEABLE_STATE } from './shared/cache-scope';
import { makeModifierText } from './shared/editor-display';
import { isHidden } from './shared/hideable';
import { removeMetadataKeys } from './shared/metadata';
import {
isMetadataFlagEnabled,
removeMetadataKeys,
toggleMetadataFlag
} from './shared/metadata';
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
import {
getSlotSymbol,
Expand All @@ -33,6 +37,11 @@ const DEFAULT_TTL_SECONDS = 300;
const TTL_OPTIONS = [300, 3600] as const; // 5 minutes, 1 hour
const TOGGLE_TTL_ACTION = 'toggle-ttl';

// Same key, action and metadata flag as the reset timers, so short time works
// the same way on every timer widget.
const COMPACT_METADATA_KEY = 'compact';
const TOGGLE_COMPACT_ACTION = 'toggle-compact';

const SAFETY_MARGIN = 5; // display as COLD 5s before actual expiry

// One editable glyph per display state, so nerd-font / ASCII users can replace
Expand Down Expand Up @@ -203,13 +212,20 @@ function getRemainingSeconds(lastAssistant: Date, ttlSeconds: number): number {
return ttlSeconds - SAFETY_MARGIN - elapsedSeconds;
}

function formatCountdown(remaining: number): string {
// Labeled like the reset timers ('4m 52s' / '4m52s') rather than clock
// notation, so every timer widget reads the same way. Seconds always render:
// they are the point of a cache countdown, and dropping a zero part the way
// formatUsageDuration does would make the widget jump width every minute.
function formatCountdown(remaining: number, compact: boolean): string {
if (remaining <= 0) {
return 'COLD';
}
const m = Math.floor(remaining / 60);
const s = Math.floor(remaining % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
const total = Math.floor(remaining);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const parts = [h > 0 && `${h}${compact ? 'h' : 'hr'}`, (h > 0 || m > 0) && `${m}m`, `${s}s`];
return parts.filter(Boolean).join(compact ? '' : ' ');
}

// The glyph for the current drain state (excluding HOT, handled in render).
Expand Down Expand Up @@ -245,6 +261,10 @@ export class CacheTimerWidget implements Widget {
if (ttlSeconds !== DEFAULT_TTL_SECONDS) {
modifiers.push(`ttl ${formatTtlLabel(ttlSeconds)}`);
}

if (isMetadataFlagEnabled(item, COMPACT_METADATA_KEY)) {
modifiers.push('compact');
}
return {
displayText: this.getDisplayName(),
modifierText: makeModifierText(modifiers)
Expand All @@ -260,14 +280,20 @@ export class CacheTimerWidget implements Widget {
return cycleTtl(item);
}

if (action === TOGGLE_COMPACT_ACTION) {
return toggleMetadataFlag(item, COMPACT_METADATA_KEY);
}

return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key);
const compact = isMetadataFlagEnabled(item, COMPACT_METADATA_KEY);

if (context.isPreview) {
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, FRESH_SLOT), '4:52'));
const sample = formatCountdown(292, compact);
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, FRESH_SLOT), sample));
}

const transcriptPath = context.data?.transcript_path;
Expand All @@ -290,12 +316,13 @@ export class CacheTimerWidget implements Widget {
const remaining = getRemainingSeconds(lastAssistant, ttlSeconds);
const glyph = getStateSymbol(item, remaining, ttlSeconds);

return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(glyph, formatCountdown(remaining)));
return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(glyph, formatCountdown(remaining, compact)));
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 't', label: '(t)tl', action: TOGGLE_TTL_ACTION },
{ key: 's', label: '(s)hort time', action: TOGGLE_COMPACT_ACTION },
getSymbolKeybind()
];
}
Expand Down
54 changes: 42 additions & 12 deletions src/widgets/__tests__/CacheTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ describe('CacheTimer widget', () => {

it('renders the preview as a labeled or raw sample', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item(), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: 🟢 4:52');
expect(widget.render(item({ rawValue: true }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('🟢 4:52');
expect(widget.render(item(), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: 🟢 4m 52s');
expect(widget.render(item({ rawValue: true }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('🟢 4m 52s');
expect(widget.render(item({ metadata: { compact: 'true' } }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: 🟢 4m52s');
});

it('renders n/a when no transcript is available by default', () => {
Expand Down Expand Up @@ -82,7 +83,7 @@ describe('CacheTimer widget', () => {
it(`renders the ${label} countdown with the ${icon} icon`, () => {
const widget = new CacheTimerWidget();
const out = widget.render(item(), transcriptContext([assistant(elapsed)]), DEFAULT_SETTINGS);
expect(out).toMatch(new RegExp(`^Cache: ${icon} \\d+:\\d{2}$`));
expect(out).toMatch(new RegExp(`^Cache: ${icon} (\\d+m )?\\d+s$`));
});
}

Expand All @@ -94,7 +95,7 @@ describe('CacheTimer widget', () => {
it('renders a raw countdown without the label', () => {
const widget = new CacheTimerWidget();
const out = widget.render(item({ rawValue: true }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS);
expect(out).toMatch(/^🟢 \d+:\d{2}$/);
expect(out).toMatch(/^🟢 (\d+m )?\d+s$/);
});

it('ignores sidechain rows when deriving the cache state', () => {
Expand Down Expand Up @@ -140,8 +141,8 @@ describe('CacheTimer widget', () => {

it('starts the countdown from rows with cache reads or cache writes', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_read_input_tokens: 1234 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_creation_input_tokens: 55 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_read_input_tokens: 1234 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 (\d+m )?\d+s$/);
expect(widget.render(item(), transcriptContext([assistantUsage(10, { cache_creation_input_tokens: 55 })]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 (\d+m )?\d+s$/);
});

it('finds the trailing record even when it exceeds the initial 32 KiB tail read', () => {
Expand All @@ -152,13 +153,13 @@ describe('CacheTimer widget', () => {
expect(widget.render(item(), transcriptContext([assistant(400), bigUser]), DEFAULT_SETTINGS)).toBe('Cache: 🔥 HOT');
// ...and an oversized trailing assistant row must still drive the countdown.
const bigAssistant = JSON.stringify({ type: 'assistant', timestamp: isoAgo(10), content: 'x'.repeat(64 * 1024) });
expect(widget.render(item(), transcriptContext([bigAssistant]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
expect(widget.render(item(), transcriptContext([bigAssistant]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 (\d+m )?\d+s$/);
});

it('finds a valid trailing record larger than 1 MiB', () => {
const widget = new CacheTimerWidget();
const huge = JSON.stringify({ type: 'assistant', timestamp: isoAgo(10), message: { usage: { cache_read_input_tokens: 42 } }, content: 'x'.repeat(2 * 1024 * 1024) });
expect(widget.render(item(), transcriptContext([huge]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
expect(widget.render(item(), transcriptContext([huge]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 (\d+m )?\d+s$/);
});

it('renders n/a after scanning a file with no parseable records', () => {
Expand All @@ -177,6 +178,7 @@ describe('CacheTimer widget', () => {
const widget = new CacheTimerWidget();
expect(widget.getCustomKeybinds()).toEqual([
{ key: 't', label: '(t)tl', action: 'toggle-ttl' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 'g', label: '(g)lyph', action: 'edit-symbol-override' }
]);
expect(widget.getHideableStates().map(state => state.key)).toEqual(['empty']);
Expand All @@ -193,26 +195,26 @@ describe('CacheTimer widget', () => {
it('renders custom state glyphs from metadata overrides', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item({ metadata: { symbolCold: 'X' } }), transcriptContext([assistant(400)]), DEFAULT_SETTINGS)).toBe('Cache: X COLD');
expect(widget.render(item({ metadata: { symbolFresh: '*' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: \* \d+:\d{2}$/);
expect(widget.render(item({ metadata: { symbolFresh: '*' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: \* (\d+m )?\d+s$/);
expect(widget.render(item({ metadata: { symbolHot: '>' } }), transcriptContext([assistant(60), pendingUser]), DEFAULT_SETTINGS)).toBe('Cache: > HOT');
});

it('drops the glyph and its space when an override is blanked', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item({ metadata: { symbolFresh: '' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: \d+:\d{2}$/);
expect(widget.render(item({ metadata: { symbolFresh: '' } }), transcriptContext([assistant(10)]), DEFAULT_SETTINGS)).toMatch(/^Cache: (\d+m )?\d+s$/);
});

it('reflects a custom fresh glyph in the preview', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item({ metadata: { symbolFresh: '#' } }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: # 4:52');
expect(widget.render(item({ metadata: { symbolFresh: '#' } }), { isPreview: true }, DEFAULT_SETTINGS)).toBe('Cache: # 4m 52s');
});

it('extends the countdown window when the TTL is set to 1 hour', () => {
const widget = new CacheTimerWidget();
// 600s in is COLD at the default 5-minute TTL...
expect(widget.render(item(), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toBe('Cache: ❄️ COLD');
// ...but still fresh under a 1-hour TTL.
expect(widget.render(item({ metadata: { ttlSeconds: '3600' } }), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 \d+:\d{2}$/);
expect(widget.render(item({ metadata: { ttlSeconds: '3600' } }), transcriptContext([assistant(600)]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 (\d+m )?\d+s$/);
});

it('falls back to the default TTL for a malformed value', () => {
Expand All @@ -228,6 +230,34 @@ describe('CacheTimer widget', () => {
expect(backToDefault?.metadata?.ttlSeconds).toBeUndefined();
});

it('drops the separator in short time mode', () => {
const widget = new CacheTimerWidget();
const context = transcriptContext([assistant(10)]);
expect(widget.render(item(), context, DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 4m \d+s$/);
expect(widget.render(item({ metadata: { compact: 'true' } }), context, DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 4m\d+s$/);
});

it('renders hours once the remaining time exceeds an hour', () => {
const widget = new CacheTimerWidget();
const context = transcriptContext([assistant(10)]);
expect(widget.render(item({ metadata: { ttlSeconds: '7200' } }), context, DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 1hr 59m \d+s$/);
expect(widget.render(item({ metadata: { ttlSeconds: '7200', compact: 'true' } }), context, DEFAULT_SETTINGS)).toMatch(/^Cache: 🟢 1h59m\d+s$/);
});

it('omits the minutes below a minute remaining', () => {
const widget = new CacheTimerWidget();
expect(widget.render(item(), transcriptContext([assistant(260)]), DEFAULT_SETTINGS)).toMatch(/^Cache: 🔴 \d+s$/);
});

it('toggles short time via the keybind and annotates the editor', () => {
const widget = new CacheTimerWidget();
const short = widget.handleEditorAction('toggle-compact', item());
expect(short?.metadata?.compact).toBe('true');
expect(widget.getEditorDisplay(short ?? item()).modifierText).toBe('(compact)');
expect(widget.getEditorDisplay(item({ metadata: { ttlSeconds: '3600', compact: 'true' } })).modifierText).toBe('(ttl 1h, compact)');
expect(widget.handleEditorAction('toggle-compact', short ?? item())?.metadata?.compact).toBe('false');
});

it('annotates the editor with a non-default TTL', () => {
const widget = new CacheTimerWidget();
expect(widget.getEditorDisplay(item({ metadata: { ttlSeconds: '3600' } })).modifierText).toBe('(ttl 1h)');
Expand Down