From c74f3fd2eff76b95f6f8ecd02fc25ed0b71d9d0a Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:15:48 +0200 Subject: [PATCH] feat(cache-timer): add a short time toggle and drop the clock notation The Cache Timer was the only timer widget without the shared (s) short time toggle, and the only one rendering its countdown as mm:ss instead of labeled units. It now renders '4m 52s' by default and '4m52s' in short time mode, matching Block Timer, Block Reset Timer and Weekly Reset Timer, and it gains hour parts for TTLs longer than an hour. --- docs/USAGE.md | 4 +- src/widgets/CacheTimer.ts | 41 +++++++++++++++--- src/widgets/__tests__/CacheTimer.test.ts | 54 ++++++++++++++++++------ 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 62cd88bcf..142b7082e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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. @@ -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 diff --git a/src/widgets/CacheTimer.ts b/src/widgets/CacheTimer.ts index a51f8f85f..4b4298aa8 100644 --- a/src/widgets/CacheTimer.ts +++ b/src/widgets/CacheTimer.ts @@ -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, @@ -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 @@ -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). @@ -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) @@ -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; @@ -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() ]; } diff --git a/src/widgets/__tests__/CacheTimer.test.ts b/src/widgets/__tests__/CacheTimer.test.ts index 675445c4a..c4b65d454 100644 --- a/src/widgets/__tests__/CacheTimer.test.ts +++ b/src/widgets/__tests__/CacheTimer.test.ts @@ -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', () => { @@ -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$`)); }); } @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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']); @@ -193,18 +195,18 @@ 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', () => { @@ -212,7 +214,7 @@ describe('CacheTimer widget', () => { // 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', () => { @@ -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)');