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
4 changes: 2 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ bun run example
- **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.
- **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.
- **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).
- **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.
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Extra Usage Utilization** / **Extra Usage Remaining** / **Extra Usage Used** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages, monthly pay-as-you-go overage usage, and current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Session Usage, the weekly percentage widgets, and Extra Usage Utilization can show either used or remaining percentage in every display mode. Session and weekly usage bars can also show a time cursor. Extra usage widgets accept known extra-usage state as complete when an account has no monthly limit configured, avoid repeated refetches and stale `[Timeout]` output, and format amounts with the API-reported billing currency when available. Reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.

### Environment, Layout & Custom
Expand Down Expand Up @@ -206,7 +206,7 @@ Widget-specific shortcuts:
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Context Bar**: `p` cycle medium/full/short/short-only progress bar
- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero
- **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, `h` hide when zero
- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty
- **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path, `g` optional leading glyph (off by default; pair with raw value to replace the `cwd:` label with the glyph)
Expand Down
26 changes: 26 additions & 0 deletions src/tui/components/items-editor/__tests__/input-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,32 @@ describe('items-editor input handlers', () => {
expect(updated?.[0]?.metadata?.mode).toBe('count');
});

it('uses v to cycle compaction counter metric', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'compaction-counter' }
];
const onUpdate = vi.fn();

handleNormalInputMode({
input: 'v',
key: {},
widgets,
selectedIndex: 0,
separatorChars: ['|', '-'],
onBack: vi.fn(),
onUpdate,
setSelectedIndex: vi.fn(),
setMoveMode: vi.fn(),
setShowClearConfirm: vi.fn(),
openWidgetPicker: vi.fn(),
getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [],
setCustomEditorWidget: vi.fn()
});

const updated = onUpdate.mock.calls[0]?.[0] as WidgetItem[] | undefined;
expect(updated?.[0]?.metadata?.metric).toBe('auto');
});

it('opens custom editor for skills list limit action', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'skills', metadata: { mode: 'list' } }
Expand Down
107 changes: 90 additions & 17 deletions src/widgets/CompactionCounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ const TOGGLE_TRIGGERS_ACTION = 'toggle-triggers';
const SHOW_TRIGGERS_METADATA_KEY = 'showTriggers';
const TOGGLE_RECLAIMED_ACTION = 'toggle-reclaimed';
const SHOW_RECLAIMED_METADATA_KEY = 'showReclaimed';
// Selectable metric. The default 'count' keeps the full composite display
// (icon, count, optional trigger split, optional reclaimed). The other metrics
// render just that one value as a raw number, so several instances can be
// composed with custom separators/symbols into a layout like "2 · 1a 1m · ↓2M".
const METRICS = ['count', 'auto', 'manual', 'unknown', 'reclaimed'] as const;
type CompactionMetric = typeof METRICS[number];
const DEFAULT_METRIC: CompactionMetric = 'count';
const METRIC_METADATA_KEY = 'metric';
const CYCLE_METRIC_ACTION = 'cycle-metric';
const RECLAIMED_SLOT: SymbolSlot = { id: 'symbolReclaimed', label: 'Reclaimed', defaultSymbol: '↓' };
const SAMPLE_STATS: CompactionData = Object.freeze({
count: 2,
Expand Down Expand Up @@ -102,6 +111,41 @@ function toggleHideZero(item: WidgetItem): WidgetItem {
};
}

function getMetric(item: WidgetItem): CompactionMetric {
const metric = item.metadata?.[METRIC_METADATA_KEY];
return (METRICS as readonly string[]).includes(metric ?? '') ? (metric as CompactionMetric) : DEFAULT_METRIC;
}

function setMetric(item: WidgetItem, metric: CompactionMetric): WidgetItem {
if (metric === DEFAULT_METRIC) {
const { [METRIC_METADATA_KEY]: removedMetric, ...restMetadata } = item.metadata ?? {};
void removedMetric;

return {
...item,
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
};
}

return {
...item,
metadata: {
...(item.metadata ?? {}),
[METRIC_METADATA_KEY]: metric
}
};
}

function getMetricValue(data: CompactionData, metric: CompactionMetric): number {
switch (metric) {
case 'count': return data.count;
case 'auto': return data.byTrigger.auto;
case 'manual': return data.byTrigger.manual;
case 'unknown': return data.byTrigger.unknown;
case 'reclaimed': return data.tokensReclaimed;
}
}

function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string {
if (tokensReclaimed <= 0) {
return '';
Expand Down Expand Up @@ -169,23 +213,32 @@ function formatCount(count: number, format: CompactionCounterFormat, icon: strin
* compaction has occurred by counting compact_boundary markers in the transcript.
*
* Shows ↻ N by default, including ↻ 0 before compaction occurs. Can be
* configured to hide when count is 0.
* configured to hide when count is 0. A `metric` selector switches it to emit a
* single raw value (count, auto, manual, unknown, or reclaimed) so several
* instances can be composed into a custom layout.
*/
export class CompactionCounterWidget implements Widget {
getDefaultColor(): string { return 'yellow'; }
getDescription(): string { return 'Count of context compaction events in the current session.'; }
getDisplayName(): string { return 'Compaction Counter'; }
getCategory(): string { return 'Context'; }
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
const modifiers: string[] = [getFormat(item)];
if (isNerdFontEnabled(item)) {
modifiers.push('nerd font');
}
if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) {
modifiers.push('trigger split');
}
if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) {
modifiers.push('reclaimed');
const metric = getMetric(item);
const modifiers: string[] = [];

if (metric !== DEFAULT_METRIC) {
modifiers.push(`${metric} value`);
} else {
modifiers.push(getFormat(item));
if (isNerdFontEnabled(item)) {
modifiers.push('nerd font');
}
if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) {
modifiers.push('trigger split');
}
if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) {
modifiers.push('reclaimed');
}
}
if (isHideZeroEnabled(item)) {
modifiers.push('hide zero');
Expand All @@ -198,6 +251,13 @@ export class CompactionCounterWidget implements Widget {
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === CYCLE_METRIC_ACTION) {
const currentMetric = getMetric(item);
const nextMetric = METRICS[(METRICS.indexOf(currentMetric) + 1) % METRICS.length] ?? DEFAULT_METRIC;

return setMetric(item, nextMetric);
}

if (action === CYCLE_FORMAT_ACTION) {
const currentFormat = getFormat(item);
const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT;
Expand Down Expand Up @@ -226,28 +286,41 @@ export class CompactionCounterWidget implements Widget {

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
void settings;
const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON;
const data = context.isPreview ? SAMPLE_STATS : (context.compactionData ?? ZERO_COMPACTION_STATS);
const metric = getMetric(item);

if (context.isPreview) {
return formatStats(SAMPLE_STATS, item, icon);
if (metric !== DEFAULT_METRIC) {
const value = getMetricValue(data, metric);
if (value === 0 && isHideZeroEnabled(item) && !context.isPreview) {
return null;
}
return metric === 'reclaimed' ? formatTokens(value) : String(value);
}

const data = context.compactionData ?? ZERO_COMPACTION_STATS;
if (data.count === 0 && isHideZeroEnabled(item))
if (data.count === 0 && isHideZeroEnabled(item) && !context.isPreview) {
return null;
}

const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON;
return formatStats(data, item, icon);
}

getCustomKeybinds(item?: WidgetItem): CustomKeybind[] {
const keybinds: CustomKeybind[] = [
{ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }
{ key: 'v', label: '(v)alue', action: CYCLE_METRIC_ACTION }
];

// The format / glyph / trigger toggles only shape the composite 'count'
// display; a single-metric value just needs the metric and hide-zero.
if (item !== undefined && getMetric(item) !== DEFAULT_METRIC) {
keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION });
return keybinds;
}

keybinds.push({ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION });
if (item === undefined || getFormat(item) === DEFAULT_FORMAT) {
keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION });
}

keybinds.push({ key: 's', label: '(s)plit by trigger', action: TOGGLE_TRIGGERS_ACTION });
keybinds.push({ key: 't', label: '(t)okens reclaimed', action: TOGGLE_RECLAIMED_ACTION });
keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION });
Expand Down
101 changes: 100 additions & 1 deletion src/widgets/__tests__/CompactionCounter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,9 @@ describe('CompactionCounterWidget', () => {
});

describe('editor', () => {
it('uses f and n as keybinds for the default format', () => {
it('uses metric, format, and toggle keybinds in count mode', () => {
expect(new CompactionCounterWidget().getCustomKeybinds(ITEM)).toEqual([
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
{ key: 'f', label: '(f)ormat', action: 'cycle-format' },
{ key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' },
{ key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' },
Expand All @@ -268,6 +269,7 @@ describe('CompactionCounterWidget', () => {
...ITEM,
metadata: { format: 'text-and-number' }
})).toEqual([
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
{ key: 'f', label: '(f)ormat', action: 'cycle-format' },
{ key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' },
{ key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' },
Expand Down Expand Up @@ -410,4 +412,101 @@ describe('CompactionCounterWidget', () => {
});
});
});

describe('metric selector', () => {
it('renders only the auto-trigger count when metric is auto', () => {
expect(render({
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
item: { ...ITEM, metadata: { metric: 'auto' } }
})).toBe('3');
});

it('renders only the manual-trigger count when metric is manual', () => {
expect(render({
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
item: { ...ITEM, metadata: { metric: 'manual' } }
})).toBe('2');
});

it('renders only the unknown-trigger count when metric is unknown', () => {
expect(render({
compactionData: { count: 5, byTrigger: { auto: 3, manual: 1, unknown: 1 } },
item: { ...ITEM, metadata: { metric: 'unknown' } }
})).toBe('1');
});

it('renders the reclaimed tokens formatted when metric is reclaimed', () => {
expect(render({
compactionData: { count: 2, tokensReclaimed: 887000 },
item: { ...ITEM, metadata: { metric: 'reclaimed' } }
})).toBe('887.0k');
});

it('emits a raw value, ignoring format and icon settings', () => {
expect(render({
compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } },
item: { ...ITEM, metadata: { metric: 'auto', format: 'icon-space-number', nerdFont: 'true' } }
})).toBe('3');
});

it('hides a zero metric value when hide zero is enabled', () => {
expect(render({
compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } },
item: { ...ITEM, metadata: { metric: 'auto', hideZero: 'true' } }
})).toBeNull();
});

it('still shows a zero metric value when hide zero is off', () => {
expect(render({
compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } },
item: { ...ITEM, metadata: { metric: 'auto' } }
})).toBe('0');
});

it('shows the sample metric value in preview mode, ignoring hide zero', () => {
expect(render({
isPreview: true,
item: { ...ITEM, metadata: { metric: 'unknown', hideZero: 'true' } }
})).toBe('0');
expect(render({
isPreview: true,
item: { ...ITEM, metadata: { metric: 'reclaimed' } }
})).toBe('120.0k');
});

it('shows the metric in the editor display', () => {
expect(new CompactionCounterWidget().getEditorDisplay({
...ITEM,
metadata: { metric: 'reclaimed', hideZero: 'true' }
})).toEqual({
displayText: 'Compaction Counter',
modifierText: '(reclaimed value, hide zero)'
});
});

it('uses only metric and hide-zero keybinds in metric mode', () => {
expect(new CompactionCounterWidget().getCustomKeybinds({
...ITEM,
metadata: { metric: 'auto' }
})).toEqual([
{ key: 'v', label: '(v)alue', action: 'cycle-metric' },
{ key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }
]);
});

it('cycles count -> auto -> manual -> unknown -> reclaimed -> count', () => {
const widget = new CompactionCounterWidget();
const auto = widget.handleEditorAction('cycle-metric', ITEM);
const manual = widget.handleEditorAction('cycle-metric', auto ?? ITEM);
const unknown = widget.handleEditorAction('cycle-metric', manual ?? ITEM);
const reclaimed = widget.handleEditorAction('cycle-metric', unknown ?? ITEM);
const count = widget.handleEditorAction('cycle-metric', reclaimed ?? ITEM);

expect(auto?.metadata?.metric).toBe('auto');
expect(manual?.metadata?.metric).toBe('manual');
expect(unknown?.metadata?.metric).toBe('unknown');
expect(reclaimed?.metadata?.metric).toBe('reclaimed');
expect(count?.metadata?.metric).toBeUndefined();
});
});
});