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 @@ -327,13 +327,13 @@ Supported states by widget family:
- **Session Cost**: `zero` hides `$0.00`
- **Session Clock**: `zero` hides durations under one minute
- **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active
- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available
- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available, `zero` hides the timer while its period's usage (session or weekly) is 0% and no reset window has started
- **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists
- **Output Style**: `default-value` hides the widget when the style is `default`
- **Compaction Counter**: `zero` hides the counter before any compaction occurs
- **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
- **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; on Extra Usage Used and Extra Usage Utilization, `zero` hides the widget while nothing has been spent (Extra Usage Remaining has no `zero` state)
- **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, `zero` hides the widget while usage is 0% (also when showing remaining)
- **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
- **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

Expand Down
108 changes: 108 additions & 0 deletions src/__tests__/cli-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type * as childProcess from 'child_process';
import * as fs from 'node:fs';
import { createRequire } from 'node:module';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
afterAll,
describe,
expect,
it
} from 'vitest';

// End-to-end smoke test: spawns the real CLI in piped mode against real git
// fixtures. Must run outside the test process because other suites mock
// child_process globally under Bun.
const require = createRequire(import.meta.url);
const { execFileSync } = require('node:child_process') as typeof childProcess;

const ANSI_CODES = /\x1b\[[0-9;]*m/g;
// The renderer emits non-breaking spaces inside widget text; normalize them
// so plain-ASCII assertions stay readable.
const NBSP = new RegExp(String.fromCharCode(160), 'g');
const entryPath = fileURLToPath(new URL('../ccstatusline.ts', import.meta.url));
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cli-smoke-'));
const tempHome = path.join(tempRoot, 'home');
const configPath = path.join(tempRoot, 'settings.json');
const transcriptPath = path.join(tempRoot, 'transcript.jsonl');

// Fixed minimal config so the smoke does not depend on the developer's live
// settings or on the default widget set.
fs.mkdirSync(tempHome, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
version: 4,
lines: [[
{ id: 'smoke-branch', type: 'git-branch' },
{ id: 'smoke-changes', type: 'git-changes' },
{ id: 'smoke-ahead-behind', type: 'git-ahead-behind' }
]]
}), 'utf-8');
fs.writeFileSync(transcriptPath, '', 'utf-8');

function git(cwd: string, ...args: string[]): void {
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], {
cwd,
stdio: 'pipe'
});
}

function initRepo(name: string): string {
const repoPath = path.join(tempRoot, name);
fs.mkdirSync(repoPath, { recursive: true });
git(repoPath, 'init', '-b', 'main', '--quiet');
git(repoPath, 'commit', '--allow-empty', '--quiet', '-m', 'init');
return repoPath;
}

function createDivergedClone(): string {
const originPath = initRepo('smoke-origin');
const workPath = path.join(tempRoot, 'smoke-work');
git(tempRoot, 'clone', '--quiet', originPath, workPath);
git(workPath, 'commit', '--allow-empty', '--quiet', '-m', 'local');
git(originPath, 'commit', '--allow-empty', '--quiet', '-m', 'remote');
git(workPath, 'fetch', '--quiet', 'origin');
return workPath;
}

function renderLine(cwd: string): string {
const stdout = execFileSync('bun', [entryPath, '--config', configPath], {
input: JSON.stringify({
model: { id: 'claude-sonnet-4-5' },
transcript_path: transcriptPath,
cwd
}),
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }
});
return stdout.replace(ANSI_CODES, '').replace(NBSP, ' ');
}

afterAll(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});

describe('CLI pipe smoke (spawned process, real git fixtures)', () => {
it('renders the no-git state outside a repository', () => {
expect(renderLine(tempRoot)).toContain('no git');
});

it('renders ahead and behind counts for a diverged branch', () => {
const text = renderLine(createDivergedClone());
expect(text).toContain('↑1');
expect(text).toContain('↓1');
});

it('hides the zero state on a synced branch', () => {
const originPath = initRepo('smoke-synced-origin');
const clonePath = path.join(tempRoot, 'smoke-synced');
git(tempRoot, 'clone', '--quiet', originPath, clonePath);
expect(renderLine(clonePath)).not.toContain('↑0↓0');
});

it('renders the no-upstream state without an upstream', () => {
expect(renderLine(initRepo('smoke-noupstream'))).toContain('(no upstream)');
});
});
6 changes: 4 additions & 2 deletions src/utils/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ const GIT_REVIEW_HIDE_RULE: HideFlagRule = {
// placeholder. hideIfDisabled never covered that placeholder, so it stays mapped
// to 'disabled' alone and only a hand-written list can enable it.
const EXTRA_USAGE_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data'] };
// Used and Utilization also offer `zero`, which a hand-written hide list can carry in.
const EXTRA_USAGE_ZERO_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data', 'zero'] };
const CACHE_HIDE_RULE: HideFlagRule = { legacy: { hideWhenEmpty: ['empty'] }, stateOrder: ['empty'] };

export const V4_HIDE_FLAG_RULES: Record<string, HideFlagRule> = {
Expand Down Expand Up @@ -209,9 +211,9 @@ export const V4_HIDE_FLAG_RULES: Record<string, HideFlagRule> = {
'cache-write': CACHE_HIDE_RULE,
'cache-hit-rate': CACHE_HIDE_RULE,
'cache-timer': CACHE_HIDE_RULE,
'extra-usage-utilization': EXTRA_USAGE_HIDE_RULE,
'extra-usage-utilization': EXTRA_USAGE_ZERO_HIDE_RULE,
'extra-usage-remaining': EXTRA_USAGE_HIDE_RULE,
'extra-usage-used': EXTRA_USAGE_HIDE_RULE
'extra-usage-used': EXTRA_USAGE_ZERO_HIDE_RULE
};

const V4_LEGACY_HIDE_KEYS = [
Expand Down
7 changes: 6 additions & 1 deletion src/widgets/BlockResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './shared/timezone-editor';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageDisplayModifierText,
Expand Down Expand Up @@ -71,7 +72,7 @@ export class BlockResetTimerWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down Expand Up @@ -137,6 +138,10 @@ export class BlockResetTimerWidget implements Widget {
}

const usageData = context.usageData ?? {};
if (usageData.sessionUsage === 0 && !usageData.sessionResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) {
return null;
}

const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);

if (!window) {
Expand Down
11 changes: 9 additions & 2 deletions src/widgets/ExtraUsageUsed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import { formatUsageCurrency } from './shared/currency';
import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled';
import { isHidden } from './shared/hideable';
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE
} from './shared/usage-display';

export class ExtraUsageUsedWidget implements Widget {
getDefaultColor(): string { return 'green'; }
Expand All @@ -26,7 +29,7 @@ export class ExtraUsageUsedWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE];
return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
Expand All @@ -50,6 +53,10 @@ export class ExtraUsageUsedWidget implements Widget {
return null;
}

if (data.extraUsageUsed === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) {
return null;
}

// extraUsageUsed is in cents
const usedDollars = data.extraUsageUsed / 100;
const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format);
Expand Down
7 changes: 6 additions & 1 deletion src/widgets/ExtraUsageUtilization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { makeTimerProgressBar } from './shared/progress-bar';
import { formatRawOrLabeledValue } from './shared/raw-or-labeled';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageDisplayModifierText,
Expand All @@ -45,7 +46,7 @@ export class ExtraUsageUtilizationWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE];
return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down Expand Up @@ -101,6 +102,10 @@ export class ExtraUsageUtilizationWidget implements Widget {

// extraUsageUtilization is already a percentage (0-100), not a fraction
const percent = Math.max(0, Math.min(100, data.extraUsageUtilization));
if (percent === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) {
return null;
}

const renderedPercent = inverted ? 100 - percent : percent;

if (isUsageProgressMode(displayMode)) {
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/FableWeeklyUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
getUsagePercentCustomKeybinds
} from './shared/usage-display';
import {
Expand All @@ -31,7 +32,7 @@ export class FableWeeklyUsageWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/SessionUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
getUsagePercentCustomKeybinds
} from './shared/usage-display';
import {
Expand All @@ -31,7 +32,7 @@ export class SessionUsageWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/WeeklyOpusUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
getUsagePercentCustomKeybinds
} from './shared/usage-display';
import {
Expand All @@ -31,7 +32,7 @@ export class WeeklyOpusUsageWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down
7 changes: 6 additions & 1 deletion src/widgets/WeeklyResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from './shared/timezone-editor';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageLocale,
Expand Down Expand Up @@ -141,7 +142,7 @@ export class WeeklyResetTimerWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down Expand Up @@ -221,6 +222,10 @@ export class WeeklyResetTimerWidget implements Widget {
}

const usageData = context.usageData ?? {};
if (usageData.weeklyUsage === 0 && !usageData.weeklyResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) {
return null;
}

const window = resolveWeeklyUsageWindow(usageData);

if (!window) {
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/WeeklySonnetUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
getUsagePercentCustomKeybinds
} from './shared/usage-display';
import {
Expand All @@ -31,7 +32,7 @@ export class WeeklySonnetUsageWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down
3 changes: 2 additions & 1 deletion src/widgets/WeeklyUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import {
USAGE_NO_DATA_HIDEABLE_STATE,
USAGE_ZERO_HIDEABLE_STATE,
getUsagePercentCustomKeybinds
} from './shared/usage-display';
import {
Expand All @@ -31,7 +32,7 @@ export class WeeklyUsageWidget implements Widget {
}

getHideableStates(): HideableState[] {
return [USAGE_NO_DATA_HIDEABLE_STATE];
return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
Expand Down
24 changes: 22 additions & 2 deletions src/widgets/__tests__/BlockResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,28 @@ describe('BlockResetTimerWidget', () => {
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]');
});

it('declares the no-data hideable state', () => {
expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data']);
it('declares the no-data and zero hideable states', () => {
expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']);
});

it('hides when sessionUsage is zero and the zero state is enabled', () => {
const widget = new BlockResetTimerWidget();
const item: WidgetItem = { id: 'reset', type: 'reset-timer', metadata: { hide: 'zero' } };

mockResolveUsageWindowWithFallback.mockReturnValue({
sessionDurationMs: 18000000,
elapsedMs: 3600000,
remainingMs: 14400000,
elapsedPercent: 20,
remainingPercent: 80
});
mockFormatUsageDuration.mockReturnValue('4hr');

expect(render(widget, item, { usageData: { sessionUsage: 0 } })).toBeNull();
// A null bucket pins usage to 0 while limits[] still supplies the reset time
expect(render(widget, item, { usageData: { sessionUsage: 0, sessionResetAt: '2030-07-01T00:00:00.000Z' } })).toBe('Reset: 4hr');
expect(render(widget, item, { usageData: { sessionUsage: 5 } })).toBe('Reset: 4hr');
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { sessionUsage: 0 } })).toBe('Reset: 4hr');
});

// One state covers both placeholders, since either means the same thing to
Expand Down
Loading
Loading