diff --git a/docs/USAGE.md b/docs/USAGE.md index b6a89ecf..0323ffef 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 @@ -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 diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 8a216035..1c2d617c 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -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, diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 21879c32..84b286db 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -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 | 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, @@ -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`); + } } }); }); diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 736601f1..1a9782ad 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -4,6 +4,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -20,6 +21,7 @@ import { resolveUsageWindowWithFallback } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { LOCALE_EDITOR_ACTION, renderUsageLocaleEditor @@ -31,6 +33,7 @@ import { renderUsageTimezoneEditor } from './shared/timezone-editor'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -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); @@ -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); } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 1c1f670c..bdeb4e8d 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -4,6 +4,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -21,6 +22,7 @@ import { } from '../utils/usage'; import { makeModifierText } from './shared/editor-display'; +import { isHidden } from './shared/hideable'; import { LOCALE_EDITOR_ACTION, renderUsageLocaleEditor @@ -36,6 +38,7 @@ import { renderUsageTimezoneEditor } from './shared/timezone-editor'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageLocale, @@ -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); @@ -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); } @@ -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; diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index 8845bd3a..a930bcf1 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -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(); @@ -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' } ]); diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index 6dfb976a..e35a9d7d 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -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(); @@ -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' } @@ -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, diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index 2e66cbac..b2ad166a 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -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; @@ -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' };