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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,8 @@ bun run example
- **Memory Usage** - Shows system memory usage (used/total)
- **Session Usage** - Shows daily/session API usage percentage
- **Weekly Usage** - Shows weekly API usage percentage
- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window
- **Weekly Reset Timer** - Shows time remaining until weekly usage reset
- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window (or exact reset datetime in date mode)
- **Weekly Reset Timer** - Shows time remaining until weekly usage reset (or exact reset datetime in date mode)
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
- **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle)
- **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured)
Expand Down Expand Up @@ -550,8 +550,8 @@ Widget-specific shortcuts:
- **Git widgets**: `h` toggle hide `no git` output
- **Context % widgets**: `u` toggle used vs remaining display
- **Block Timer**: `p` cycle display mode (time/full bar/short bar)
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar)
- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar)
- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar), `s` toggle compact time/date, `d` toggle exact reset datetime
- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar), `s` toggle compact time/date, `d` toggle exact reset datetime
- **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path
- **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors
- **Link**: `u` URL, `e` link text
Expand Down
11 changes: 11 additions & 0 deletions src/utils/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../usage-types';
import {
formatUsageDuration,
formatUsageResetAt,
getUsageWindowFromResetAt,
getWeeklyUsageWindowFromResetAt,
resolveUsageWindowWithFallback,
Expand Down Expand Up @@ -146,4 +147,14 @@ describe('usage window helpers', () => {
expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m');
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m');
});

it('formats reset timestamps in UTC date mode', () => {
expect(formatUsageResetAt('2026-03-12T08:30:00.000Z')).toBe('2026-03-12 08:30 UTC');
expect(formatUsageResetAt('2026-03-12T08:30:00.000Z', true)).toBe('03-12 08:30Z');
});

it('returns null for invalid reset timestamps in date mode', () => {
expect(formatUsageResetAt(undefined)).toBeNull();
expect(formatUsageResetAt('not-a-date')).toBeNull();
});
});
26 changes: 26 additions & 0 deletions src/utils/usage-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ export function formatUsageDuration(durationMs: number, compact = false): string
return `${elapsedHours}hr ${elapsedMinutes}m`;
}

function pad(value: number): string {
return value.toString().padStart(2, '0');
}

export function formatUsageResetAt(resetAt: string | undefined, compact = false): string | null {
if (!resetAt) {
return null;
}

const resetAtMs = Date.parse(resetAt);
if (Number.isNaN(resetAtMs)) {
return null;
}

const date = new Date(resetAtMs);
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hours = pad(date.getUTCHours());
const minutes = pad(date.getUTCMinutes());

return compact
? `${month}-${day} ${hours}:${minutes}Z`
: `${year}-${month}-${day} ${hours}:${minutes} UTC`;
}

export function getUsageErrorMessage(error: UsageError): string {
switch (error) {
case 'no-credentials': return '[No credentials]';
Expand Down
1 change: 1 addition & 0 deletions src/utils/usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { fetchUsageData } from './usage-fetch';
export {
formatUsageDuration,
formatUsageResetAt,
getUsageErrorMessage,
getUsageWindowFromBlockMetrics,
getUsageWindowFromResetAt,
Expand Down
24 changes: 22 additions & 2 deletions src/widgets/BlockResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from '../types/Widget';
import {
formatUsageDuration,
formatUsageResetAt,
getUsageErrorMessage,
resolveUsageWindowWithFallback
} from '../utils/usage';
Expand All @@ -19,9 +20,11 @@ import {
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageDateMode,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageDateMode,
toggleUsageInverted
} from './shared/usage-display';

Expand All @@ -41,7 +44,7 @@ export class BlockResetTimerWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
modifierText: getUsageDisplayModifierText(item, { includeCompact: true, includeDate: true })
};
}

Expand All @@ -58,13 +61,18 @@ export class BlockResetTimerWidget implements Widget {
return toggleUsageCompact(item);
}

if (action === 'toggle-date') {
return toggleUsageDateMode(item);
}

return null;
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
const dateMode = isUsageDateMode(item);

if (context.isPreview) {
const previewPercent = inverted ? 90.0 : 10.0;
Expand All @@ -75,6 +83,10 @@ export class BlockResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}

if (dateMode) {
return formatRawOrLabeledValue(item, 'Reset: ', compact ? '03-12 08:30Z' : '2026-03-12 08:30 UTC');
}

return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m');
}

Expand All @@ -97,6 +109,13 @@ export class BlockResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`);
}

if (dateMode) {
const resetAt = formatUsageResetAt(usageData.sessionResetAt, compact);
if (resetAt) {
return formatRawOrLabeledValue(item, 'Reset: ', resetAt);
}
}

const remainingTime = formatUsageDuration(window.remainingMs, compact);
return formatRawOrLabeledValue(item, 'Reset: ', remainingTime);
}
Expand All @@ -105,7 +124,8 @@ export class BlockResetTimerWidget implements Widget {
return [
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 'd', label: '(d)ate mode', action: 'toggle-date' }
];
}

Expand Down
24 changes: 22 additions & 2 deletions src/widgets/WeeklyResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from '../types/Widget';
import {
formatUsageDuration,
formatUsageResetAt,
getUsageErrorMessage,
resolveWeeklyUsageWindow
} from '../utils/usage';
Expand All @@ -19,9 +20,11 @@ import {
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageDateMode,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageDateMode,
toggleUsageInverted
} from './shared/usage-display';

Expand All @@ -41,7 +44,7 @@ export class WeeklyResetTimerWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
modifierText: getUsageDisplayModifierText(item, { includeCompact: true, includeDate: true })
};
}

Expand All @@ -58,13 +61,18 @@ export class WeeklyResetTimerWidget implements Widget {
return toggleUsageCompact(item);
}

if (action === 'toggle-date') {
return toggleUsageDateMode(item);
}

return null;
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
const dateMode = isUsageDateMode(item);

if (context.isPreview) {
const previewPercent = inverted ? 90.0 : 10.0;
Expand All @@ -75,6 +83,10 @@ export class WeeklyResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}

if (dateMode) {
return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '03-15 08:30Z' : '2026-03-15 08:30 UTC');
}

return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '36h30m' : '36hr 30m');
}

Expand All @@ -97,6 +109,13 @@ export class WeeklyResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`);
}

if (dateMode) {
const resetAt = formatUsageResetAt(usageData.weeklyResetAt, compact);
if (resetAt) {
return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt);
}
}

const remainingTime = formatUsageDuration(window.remainingMs, compact);
return formatRawOrLabeledValue(item, 'Weekly Reset: ', remainingTime);
}
Expand All @@ -105,7 +124,8 @@ export class WeeklyResetTimerWidget implements Widget {
return [
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 'd', label: '(d)ate mode', action: 'toggle-date' }
];
}

Expand Down
20 changes: 20 additions & 0 deletions src/widgets/__tests__/BlockResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ function render(widget: BlockResetTimerWidget, item: WidgetItem, context: Render

describe('BlockResetTimerWidget', () => {
let mockFormatUsageDuration: { mockReturnValue: (value: string) => void };
let mockFormatUsageResetAt: { mockReturnValue: (value: string | null) => void };
let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void };
let mockResolveUsageWindowWithFallback: { mockReturnValue: (value: UsageWindowMetrics | null) => void };

beforeEach(() => {
vi.restoreAllMocks();
mockFormatUsageDuration = vi.spyOn(usage, 'formatUsageDuration');
mockFormatUsageResetAt = vi.spyOn(usage, 'formatUsageResetAt');
mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage');
mockResolveUsageWindowWithFallback = vi.spyOn(usage, 'resolveUsageWindowWithFallback');
});
Expand Down Expand Up @@ -111,6 +113,24 @@ describe('BlockResetTimerWidget', () => {
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('3hr 45m');
});

it('shows reset timestamp in date mode', () => {
const widget = new BlockResetTimerWidget();

mockResolveUsageWindowWithFallback.mockReturnValue({
sessionDurationMs: 18000000,
elapsedMs: 4500000,
remainingMs: 13500000,
elapsedPercent: 25,
remainingPercent: 75
});
mockFormatUsageResetAt.mockReturnValue('2026-03-12 08:30 UTC');

expect(render(widget,
{ id: 'reset', type: 'reset-timer', metadata: { absolute: 'true' } },
{ usageData: { sessionResetAt: '2026-03-12T08:30:00.000Z' } }
)).toBe('Reset: 2026-03-12 08:30 UTC');
});

runUsageTimerEditorSuite({
baseItem: { id: 'reset', type: 'reset-timer' },
createWidget: () => new BlockResetTimerWidget(),
Expand Down
20 changes: 20 additions & 0 deletions src/widgets/__tests__/WeeklyResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ function render(widget: WeeklyResetTimerWidget, item: WidgetItem, context: Rende

describe('WeeklyResetTimerWidget', () => {
let mockFormatUsageDuration: { mockReturnValue: (value: string) => void };
let mockFormatUsageResetAt: { mockReturnValue: (value: string | null) => void };
let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void };
let mockResolveWeeklyUsageWindow: { mockReturnValue: (value: UsageWindowMetrics | null) => void };

beforeEach(() => {
vi.restoreAllMocks();
mockFormatUsageDuration = vi.spyOn(usage, 'formatUsageDuration');
mockFormatUsageResetAt = vi.spyOn(usage, 'formatUsageResetAt');
mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage');
mockResolveWeeklyUsageWindow = vi.spyOn(usage, 'resolveWeeklyUsageWindow');
});
Expand Down Expand Up @@ -111,6 +113,24 @@ describe('WeeklyResetTimerWidget', () => {
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('120hr 15m');
});

it('shows weekly reset timestamp in date mode', () => {
const widget = new WeeklyResetTimerWidget();

mockResolveWeeklyUsageWindow.mockReturnValue({
sessionDurationMs: 604800000,
elapsedMs: 171900000,
remainingMs: 432900000,
elapsedPercent: 28.4216269841,
remainingPercent: 71.5783730159
});
mockFormatUsageResetAt.mockReturnValue('2026-03-15 08:30 UTC');

expect(render(widget,
{ id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { absolute: 'true' } },
{ usageData: { weeklyResetAt: '2026-03-15T08:30:00.000Z' } }
)).toBe('Weekly Reset: 2026-03-15 08:30 UTC');
});

runUsageTimerEditorSuite({
baseItem: { id: 'weekly-reset', type: 'weekly-reset-timer' },
createWidget: () => new WeeklyResetTimerWidget(),
Expand Down
14 changes: 13 additions & 1 deletion src/widgets/__tests__/helpers/usage-widget-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const EXPECTED_USAGE_KEYBINDS: CustomKeybind[] = [
const EXPECTED_TIMER_KEYBINDS: CustomKeybind[] = [
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 'd', label: '(d)ate mode', action: 'toggle-date' }
];

function getUsageContext(field: 'sessionUsage' | 'weeklyUsage', value: number): RenderContext {
Expand Down Expand Up @@ -226,4 +227,15 @@ export function runUsageTimerEditorSuite<TWidget extends UsageWidgetLike & { get
expect(cleared?.metadata?.compact).toBe('false');
expect(widget.getEditorDisplay({ ...config.baseItem, metadata: { compact: 'true' } }).modifierText).toBe('(compact)');
});

it('toggles date metadata and shows date modifier text', () => {
const widget = config.createWidget();

const dated = widget.handleEditorAction('toggle-date', config.baseItem);
const cleared = widget.handleEditorAction('toggle-date', dated ?? config.baseItem);

expect(dated?.metadata?.absolute).toBe('true');
expect(cleared?.metadata?.absolute).toBe('false');
expect(widget.getEditorDisplay({ ...config.baseItem, metadata: { absolute: 'true' } }).modifierText).toBe('(date)');
});
}
17 changes: 16 additions & 1 deletion src/widgets/shared/usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ export function isUsageCompact(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, 'compact');
}

export function isUsageDateMode(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, 'absolute');
}

export function toggleUsageCompact(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'compact');
}

interface UsageDisplayModifierOptions { includeCompact?: boolean }
export function toggleUsageDateMode(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'absolute');
}

interface UsageDisplayModifierOptions {
includeCompact?: boolean;
includeDate?: boolean;
}

export function getUsageDisplayModifierText(
item: WidgetItem,
Expand All @@ -59,6 +70,10 @@ export function getUsageDisplayModifierText(
modifiers.push('compact');
}

if (options.includeDate && isUsageDateMode(item)) {
modifiers.push('date');
}

return makeModifierText(modifiers);
}

Expand Down