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
5 changes: 3 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ Widget-specific shortcuts:
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / Weekly Fable Usage / Extra Usage Utilization**: `p` cycle percentage/full bar/medium bar/short bar/short bar only and `u` switch between used and remaining percentage in every display mode. The editor row labels the current direction as `used` or `remaining`, while the `u` helper names the direction it will switch to. Session and weekly usage widgets use `t` to toggle the time cursor in bar modes.
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
- **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
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `f` 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, `o` toggle hours-only in time mode, `f` 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
- **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
Expand Down Expand Up @@ -281,6 +281,7 @@ 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
- **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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,14 +646,14 @@ describe('items-editor input handlers', () => {
expect(updated?.[0]?.metadata?.absolute).toBe('true');
});

it('uses h to toggle reset timer hour format in timestamp mode', () => {
it('uses f to toggle reset timer hour format in timestamp mode', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'reset-timer', metadata: { absolute: 'true' } }
];
const onUpdate = vi.fn();

handleNormalInputMode({
input: 'h',
input: 'f',
key: {},
widgets,
selectedIndex: 0,
Expand Down
31 changes: 25 additions & 6 deletions src/utils/__tests__/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,22 @@ describe('legacy widget type aliases', () => {
});

describe('hideable state keybind reservation', () => {
it('widgets declaring hideable states leave the shared hide key free', () => {
// Widgets vary their keybinds by display mode, so an item-free call sees
// only one branch. These cover the metadata the usage and timer widgets
// branch on, so a bind offered in just one mode still gets caught.
const KEYBIND_MODE_PROBES: (Record<string, string> | undefined)[] = [
undefined,
{},
{ absolute: 'true' },
{ display: 'progress' },
{ display: 'progress-short' },
{ display: 'slider' },
{ display: 'slider-only' },
{ hours: 'true' },
{ absolute: 'true', hours: 'true' }
];

it('widgets declaring hideable states leave the shared hide key free in every mode', () => {
const reservedKey = getHideKeybind().key;
const settings: Settings = {
...DEFAULT_SETTINGS,
Expand All @@ -169,11 +184,15 @@ describe('hideable state keybind reservation', () => {
continue;
}

// The items editor appends the shared hide keybind last and
// keybind matching takes the first hit, so a widget-level binding
// would shadow the hide editor
const keys = (widget?.getCustomKeybinds?.() ?? []).map(keybind => keybind.key);
expect(keys).not.toContain(reservedKey);
for (const metadata of KEYBIND_MODE_PROBES) {
// The items editor appends the shared hide keybind last and
// keybind matching takes the first hit, so a widget-level
// binding would shadow the hide editor
const item = metadata === undefined ? undefined : { id: '1', type, metadata };
const keys = (widget?.getCustomKeybinds?.(item) ?? []).map(keybind => keybind.key);
expect(`${type}/${JSON.stringify(metadata)} binds ${keys.includes(reservedKey) ? reservedKey : 'nothing reserved'}`)
.toBe(`${type}/${JSON.stringify(metadata)} binds nothing reserved`);
}
}
});
});
Expand Down
11 changes: 11 additions & 0 deletions src/widgets/BlockResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
HideableState,
Widget,
WidgetEditorDisplay,
WidgetEditorProps,
Expand All @@ -20,6 +21,7 @@ import {
resolveUsageWindowWithFallback
} from '../utils/usage';

import { isHidden } from './shared/hideable';
import {
LOCALE_EDITOR_ACTION,
renderUsageLocaleEditor
Expand All @@ -31,6 +33,7 @@ import {
renderUsageTimezoneEditor
} from './shared/timezone-editor';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageDisplayModifierText,
Expand Down Expand Up @@ -67,6 +70,10 @@ export class BlockResetTimerWidget implements Widget {
};
}

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

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-progress') {
return cycleUsageDisplayMode(item, ['compact', 'absolute'], true);
Expand Down Expand Up @@ -133,6 +140,10 @@ export class BlockResetTimerWidget implements Widget {
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);

if (!window) {
if (isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key)) {
return null;
}

if (usageData.error) {
return getUsageErrorMessage(usageData.error);
}
Expand Down
13 changes: 12 additions & 1 deletion src/widgets/WeeklyResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
CustomKeybind,
HideableState,
Widget,
WidgetEditorDisplay,
WidgetEditorProps,
Expand All @@ -21,6 +22,7 @@ import {
} from '../utils/usage';

import { makeModifierText } from './shared/editor-display';
import { isHidden } from './shared/hideable';
import {
LOCALE_EDITOR_ACTION,
renderUsageLocaleEditor
Expand All @@ -36,6 +38,7 @@ import {
renderUsageTimezoneEditor
} from './shared/timezone-editor';
import {
USAGE_NO_DATA_HIDEABLE_STATE,
cycleUsageDisplayMode,
getUsageDisplayMode,
getUsageLocale,
Expand Down Expand Up @@ -137,6 +140,10 @@ export class WeeklyResetTimerWidget implements Widget {
};
}

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

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === 'toggle-progress') {
return cycleUsageDisplayMode(item, ['compact', 'hours', 'absolute'], true);
Expand Down Expand Up @@ -217,6 +224,10 @@ export class WeeklyResetTimerWidget implements Widget {
const window = resolveWeeklyUsageWindow(usageData);

if (!window) {
if (isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key)) {
return null;
}

if (usageData.error) {
return getUsageErrorMessage(usageData.error);
}
Expand Down Expand Up @@ -265,7 +276,7 @@ export class WeeklyResetTimerWidget implements Widget {
const mode = item ? getUsageDisplayMode(item) : 'time';
const isBarMode = isUsageProgressMode(mode) || isUsageSliderMode(mode);
if (!item || (!isBarMode && !isUsageDateMode(item))) {
keybinds.push({ key: 'h', label: '(h)ours only', action: 'toggle-hours' });
keybinds.push({ key: 'o', label: '(o)nly hours', action: 'toggle-hours' });
}

return keybinds;
Expand Down
30 changes: 29 additions & 1 deletion src/widgets/__tests__/BlockResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,34 @@ 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']);
});

// One state covers both placeholders, since either means the same thing to
// a reader: the widget has nothing to report yet.
it.each([
['a usage error', { error: 'timeout' as const }],
['no data at all', {}]
])('hides %s when the no-data state is enabled', (_label, usageData) => {
const widget = new BlockResetTimerWidget();

mockResolveUsageWindowWithFallback.mockReturnValue(null);
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');

expect(render(widget, { id: 'reset', type: 'reset-timer', metadata: { hide: 'no-data' } }, { usageData })).toBeNull();
});

it('keeps both placeholders when the no-data state is off', () => {
const widget = new BlockResetTimerWidget();

mockResolveUsageWindowWithFallback.mockReturnValue(null);
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');

expect(render(widget, { id: 'reset', type: 'reset-timer', metadata: { hide: '' } }, { usageData: {} })).toBe('Reset: [Loading]');
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
});

it('shows raw value without label in time mode', () => {
const widget = new BlockResetTimerWidget();

Expand Down Expand Up @@ -179,7 +207,7 @@ describe('BlockResetTimerWidget', () => {
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 't', label: '(t)imestamp', action: 'toggle-date' },
{ key: 'h', label: '12/24 (h)our', action: 'toggle-hour-format' },
{ key: 'f', label: '12/24 (f)ormat', action: 'toggle-hour-format' },
{ key: 'z', label: 'time(z)one', action: 'edit-timezone' },
{ key: 'l', label: '(l)ocale', action: 'edit-locale' }
]);
Expand Down
32 changes: 30 additions & 2 deletions src/widgets/__tests__/WeeklyResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ describe('WeeklyResetTimerWidget', () => {
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]');
});

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

// One state covers both placeholders, since either means the same thing to
// a reader: the widget has nothing to report yet.
it.each([
['a usage error', { error: 'timeout' as const }],
['no data at all', {}]
])('hides %s when the no-data state is enabled', (_label, usageData) => {
const widget = new WeeklyResetTimerWidget();

mockResolveWeeklyUsageWindow.mockReturnValue(null);
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');

expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { hide: 'no-data' } }, { usageData })).toBeNull();
});

it('keeps both placeholders when the no-data state is off', () => {
const widget = new WeeklyResetTimerWidget();

mockResolveWeeklyUsageWindow.mockReturnValue(null);
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');

expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { hide: '' } }, { usageData: {} })).toBe('Weekly Reset: [Loading]');
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
});

it('shows raw value without label in time mode', () => {
const widget = new WeeklyResetTimerWidget();

Expand Down Expand Up @@ -334,7 +362,7 @@ describe('WeeklyResetTimerWidget', () => {
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 't', label: '(t)imestamp', action: 'toggle-date' },
{ key: 'h', label: '12/24 (h)our', action: 'toggle-hour-format' },
{ key: 'f', label: '12/24 (f)ormat', action: 'toggle-hour-format' },
{ key: 'w', label: '(w)eekday', action: 'toggle-weekday' },
{ key: 'z', label: 'time(z)one', action: 'edit-timezone' },
{ key: 'l', label: '(l)ocale', action: 'edit-locale' }
Expand Down Expand Up @@ -444,7 +472,7 @@ describe('WeeklyResetTimerWidget', () => {
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 't', label: '(t)imestamp', action: 'toggle-date' },
{ key: 'h', label: '(h)ours only', action: 'toggle-hours' }
{ key: 'o', label: '(o)nly hours', action: 'toggle-hours' }
],
supportsDateMode: true,
supportsSliderMode: true,
Expand Down
10 changes: 6 additions & 4 deletions src/widgets/shared/usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ import {

export type UsageDisplayMode = 'time' | 'progress' | 'progress-short' | 'slider' | 'slider-only';

// Shared by the usage percentage widgets. The reset timers render the same
// error placeholders but cannot declare this state: they bind 'h' for the
// hour-format toggle, which would shadow the shared hide keybind
// Shared by the usage percentage widgets and the reset timers, which render the
// same error placeholders
export const USAGE_NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when usage data is unavailable' };

const SLIDER_WIDTH = 10;
Expand All @@ -29,7 +28,10 @@ const INVERT_TOGGLE_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill',
const COMPACT_TOGGLE_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' };
const CURSOR_TOGGLE_KEYBIND: CustomKeybind = { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' };
const DATE_TOGGLE_KEYBIND: CustomKeybind = { key: 't', label: '(t)imestamp', action: 'toggle-date' };
const HOUR_FORMAT_TOGGLE_KEYBIND: CustomKeybind = { key: 'h', label: '12/24 (h)our', action: 'toggle-hour-format' };
// 'h' opens the shared hide checklist, and the items editor appends that bind
// last while matching takes the first hit, so a widget-level 'h' would make the
// checklist unreachable in the modes that offer this toggle.
const HOUR_FORMAT_TOGGLE_KEYBIND: CustomKeybind = { key: 'f', label: '12/24 (f)ormat', action: 'toggle-hour-format' };
const WEEKDAY_TOGGLE_KEYBIND: CustomKeybind = { key: 'w', label: '(w)eekday', action: 'toggle-weekday' };
const TIMEZONE_KEYBIND: CustomKeybind = { key: 'z', label: 'time(z)one', action: 'edit-timezone' };
const LOCALE_KEYBIND: CustomKeybind = { key: 'l', label: '(l)ocale', action: 'edit-locale' };
Expand Down