From da7eaffe0362013ec0bcbcf25e45d9017b3976eb Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Tue, 3 Mar 2026 18:24:39 -0500 Subject: [PATCH] refactor: implement phase 3 widget pattern extraction with tests --- src/widgets/BlockTimer.ts | 105 +++++------------- src/widgets/ContextPercentage.ts | 36 +++--- src/widgets/ContextPercentageUsable.ts | 36 +++--- src/widgets/GitBranch.ts | 34 ++---- src/widgets/GitChanges.ts | 34 ++---- src/widgets/GitDeletions.ts | 34 ++---- src/widgets/GitInsertions.ts | 34 ++---- src/widgets/GitRootDir.ts | 34 ++---- src/widgets/GitWorktree.ts | 34 ++---- src/widgets/ResetTimer.ts | 97 ++++------------ src/widgets/SessionUsage.ts | 91 ++++----------- src/widgets/TokensCached.ts | 6 +- src/widgets/TokensInput.ts | 8 +- src/widgets/TokensOutput.ts | 8 +- src/widgets/TokensTotal.ts | 6 +- src/widgets/WeeklyUsage.ts | 91 ++++----------- src/widgets/__tests__/BlockTimer.test.ts | 29 +++++ .../__tests__/ContextPercentage.test.ts | 19 ++++ .../__tests__/ContextPercentageUsable.test.ts | 19 ++++ .../__tests__/GitWidgetSharedBehavior.test.ts | 58 ++++++++++ src/widgets/__tests__/ResetTimer.test.ts | 29 +++++ src/widgets/__tests__/SessionUsage.test.ts | 29 +++++ src/widgets/__tests__/TokensWidgets.test.ts | 89 ++++++++++++--- src/widgets/__tests__/WeeklyUsage.test.ts | 29 +++++ src/widgets/shared/context-inverse.ts | 26 +++++ src/widgets/shared/editor-display.ts | 3 + src/widgets/shared/git-no-git.ts | 39 +++++++ src/widgets/shared/metadata.ts | 15 +++ src/widgets/shared/raw-or-labeled.ts | 5 + src/widgets/shared/usage-display.ts | 73 ++++++++++++ 30 files changed, 654 insertions(+), 496 deletions(-) create mode 100644 src/widgets/__tests__/GitWidgetSharedBehavior.test.ts create mode 100644 src/widgets/shared/context-inverse.ts create mode 100644 src/widgets/shared/editor-display.ts create mode 100644 src/widgets/shared/git-no-git.ts create mode 100644 src/widgets/shared/metadata.ts create mode 100644 src/widgets/shared/raw-or-labeled.ts create mode 100644 src/widgets/shared/usage-display.ts diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 69c92c3..777cafd 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -12,19 +12,16 @@ import { resolveUsageWindowWithFallback } from '../utils/usage'; -type DisplayMode = 'time' | 'progress' | 'progress-short'; - -function getDisplayMode(item: WidgetItem): DisplayMode { - const mode = item.metadata?.display; - if (mode === 'progress' || mode === 'progress-short') { - return mode; - } - return 'time'; -} - -function isInverted(item: WidgetItem): boolean { - return item.metadata?.invert === 'true'; -} +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageInverted, + isUsageProgressMode, + toggleUsageInverted +} from './shared/usage-display'; function makeTimerProgressBar(percent: number, width: number): string { const clampedPercent = Math.max(0, Math.min(100, percent)); @@ -40,111 +37,63 @@ export class BlockTimerWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const mode = getDisplayMode(item); - const modifiers: string[] = []; - - if (mode === 'progress') { - modifiers.push('progress bar'); - } else if (mode === 'progress-short') { - modifiers.push('short bar'); - } - - if (isInverted(item)) { - modifiers.push('inverted'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getUsageDisplayModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - const currentMode = getDisplayMode(item); - let nextMode: DisplayMode; - - if (currentMode === 'time') { - nextMode = 'progress'; - } else if (currentMode === 'progress') { - nextMode = 'progress-short'; - } else { - nextMode = 'time'; - } - - const nextMetadata: Record = { - ...(item.metadata ?? {}), - display: nextMode - }; - - if (nextMode === 'time') { - delete nextMetadata.invert; - } - - return { - ...item, - metadata: nextMetadata - }; + return cycleUsageDisplayMode(item); } if (action === 'toggle-invert') { - return { - ...item, - metadata: { - ...item.metadata, - invert: (!isInverted(item)).toString() - } - }; + return toggleUsageInverted(item); } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getDisplayMode(item); - const inverted = isInverted(item); + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); if (context.isPreview) { const previewPercent = inverted ? 26.1 : 73.9; - const prefix = item.rawValue ? '' : 'Block '; - if (displayMode === 'progress' || displayMode === 'progress-short') { - const barWidth = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } - return item.rawValue ? '3hr 45m' : 'Block: 3hr 45m'; + return formatRawOrLabeledValue(item, 'Block: ', '3hr 45m'); } const usageData = fetchUsageData(); const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { - if (displayMode === 'progress' || displayMode === 'progress-short') { - const barWidth = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); const emptyBar = '░'.repeat(barWidth); - return item.rawValue ? `[${emptyBar}] 0.0%` : `Block [${emptyBar}] 0.0%`; + return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`); } - return item.rawValue ? '0hr 0m' : 'Block: 0hr 0m'; + return formatRawOrLabeledValue(item, 'Block: ', '0hr 0m'); } - if (displayMode === 'progress' || displayMode === 'progress-short') { - const barWidth = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); const percentage = percent.toFixed(1); - - if (item.rawValue) { - return `[${progressBar}] ${percentage}%`; - } - - return `Block [${progressBar}] ${percentage}%`; + return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`); } const elapsedTime = formatUsageDuration(window.elapsedMs); - return item.rawValue ? elapsedTime : `Block: ${elapsedTime}`; + return formatRawOrLabeledValue(item, 'Block: ', elapsedTime); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 99ae77c..fada414 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -9,51 +9,41 @@ import type { import { getContextWindowMetrics } from '../utils/context-window'; import { getContextConfig } from '../utils/model-context'; +import { + getContextInverseModifierText, + handleContextInverseAction, + isContextInverse +} from './shared/context-inverse'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class ContextPercentageWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows percentage of context window used or remaining'; } getDisplayName(): string { return 'Context %'; } getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const isInverse = item.metadata?.inverse === 'true'; - const modifiers: string[] = []; - - if (isInverse) { - modifiers.push('remaining'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getContextInverseModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-inverse') { - const currentState = item.metadata?.inverse === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - inverse: (!currentState).toString() - } - }; - } - return null; + return handleContextInverseAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const isInverse = item.metadata?.inverse === 'true'; + const isInverse = isContextInverse(item); const contextWindowMetrics = getContextWindowMetrics(context.data); if (context.isPreview) { const previewValue = isInverse ? '90.7%' : '9.3%'; - return item.rawValue ? previewValue : `Ctx: ${previewValue}`; + return formatRawOrLabeledValue(item, 'Ctx: ', previewValue); } if (contextWindowMetrics.usedPercentage !== null) { const displayPercentage = isInverse ? (100 - contextWindowMetrics.usedPercentage) : contextWindowMetrics.usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Ctx: ', `${displayPercentage.toFixed(1)}%`); } if (context.tokenMetrics) { @@ -62,7 +52,7 @@ export class ContextPercentageWidget implements Widget { const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize); const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100); const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Ctx: ', `${displayPercentage.toFixed(1)}%`); } return null; diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index a408b7f..ce293cd 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -9,41 +9,31 @@ import type { import { getContextWindowMetrics } from '../utils/context-window'; import { getContextConfig } from '../utils/model-context'; +import { + getContextInverseModifierText, + handleContextInverseAction, + isContextInverse +} from './shared/context-inverse'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class ContextPercentageUsableWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows percentage of usable context window used or remaining (80% of max before auto-compact)'; } getDisplayName(): string { return 'Context % (usable)'; } getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const isInverse = item.metadata?.inverse === 'true'; - const modifiers: string[] = []; - - if (isInverse) { - modifiers.push('remaining'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getContextInverseModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-inverse') { - const currentState = item.metadata?.inverse === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - inverse: (!currentState).toString() - } - }; - } - return null; + return handleContextInverseAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const isInverse = item.metadata?.inverse === 'true'; + const isInverse = isContextInverse(item); const model = context.data?.model; const modelId = typeof model === 'string' ? model : model?.id; const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -51,19 +41,19 @@ export class ContextPercentageUsableWidget implements Widget { if (context.isPreview) { const previewValue = isInverse ? '88.4%' : '11.6%'; - return item.rawValue ? previewValue : `Ctx(u): ${previewValue}`; + return formatRawOrLabeledValue(item, 'Ctx(u): ', previewValue); } if (contextWindowMetrics.contextLengthTokens !== null) { const usedPercentage = Math.min(100, (contextWindowMetrics.contextLengthTokens / contextConfig.usableTokens) * 100); const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx(u): ${displayPercentage.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Ctx(u): ', `${displayPercentage.toFixed(1)}%`); } if (context.tokenMetrics) { const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.usableTokens) * 100); const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx(u): ${displayPercentage.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Ctx(u): ', `${displayPercentage.toFixed(1)}%`); } return null; } diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 080f142..4239289 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -11,41 +11,31 @@ import { runGit } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } getDescription(): string { return 'Shows the current git branch name'; } getDisplayName(): string { return 'Git Branch'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { return item.rawValue ? 'main' : '⎇ main'; @@ -67,9 +57,7 @@ export class GitBranchWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index 165e059..5ee4aa3 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -11,41 +11,31 @@ import { isInsideGitWorkTree } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows git changes count (+insertions, -deletions)'; } getDisplayName(): string { return 'Git Changes'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { return '(+42,-10)'; @@ -60,9 +50,7 @@ export class GitChangesWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitDeletions.ts b/src/widgets/GitDeletions.ts index 575896a..1c58194 100644 --- a/src/widgets/GitDeletions.ts +++ b/src/widgets/GitDeletions.ts @@ -11,41 +11,31 @@ import { isInsideGitWorkTree } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitDeletionsWidget implements Widget { getDefaultColor(): string { return 'red'; } getDescription(): string { return 'Shows git deletions count'; } getDisplayName(): string { return 'Git Deletions'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { return '-10'; @@ -60,9 +50,7 @@ export class GitDeletionsWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitInsertions.ts b/src/widgets/GitInsertions.ts index 998be61..3988552 100644 --- a/src/widgets/GitInsertions.ts +++ b/src/widgets/GitInsertions.ts @@ -11,41 +11,31 @@ import { isInsideGitWorkTree } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitInsertionsWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows git insertions count'; } getDisplayName(): string { return 'Git Insertions'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { return '+42'; @@ -60,9 +50,7 @@ export class GitInsertionsWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts index 47a52c2..56bce64 100644 --- a/src/widgets/GitRootDir.ts +++ b/src/widgets/GitRootDir.ts @@ -11,41 +11,31 @@ import { runGit } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the git repository root directory name'; } getDisplayName(): string { return 'Git Root Dir'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { return 'my-repo'; @@ -76,9 +66,7 @@ export class GitRootDirWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index 6478e6f..795a529 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -10,41 +10,31 @@ import { runGit } from '../utils/git'; +import { + getHideNoGitKeybinds, + getHideNoGitModifierText, + handleToggleNoGitAction, + isHideNoGitEnabled +} from './shared/git-no-git'; + export class GitWorktreeWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows the current git worktree name'; } getDisplayName(): string { return 'Git Worktree'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoGit = item.metadata?.hideNoGit === 'true'; - const modifiers: string[] = []; - - if (hideNoGit) { - modifiers.push('hide \'no git\''); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getHideNoGitModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nogit') { - const currentState = item.metadata?.hideNoGit === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoGit: (!currentState).toString() - } - }; - } - return null; + return handleToggleNoGitAction(action, item); } render(item: WidgetItem, context: RenderContext): string | null { - const hideNoGit = item.metadata?.hideNoGit === 'true'; + const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) return item.rawValue ? 'main' : '𖠰 main'; @@ -85,9 +75,7 @@ export class GitWorktreeWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ]; + return getHideNoGitKeybinds(); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ResetTimer.ts b/src/widgets/ResetTimer.ts index 6373e3f..79ea6bb 100644 --- a/src/widgets/ResetTimer.ts +++ b/src/widgets/ResetTimer.ts @@ -13,19 +13,16 @@ import { resolveUsageWindowWithFallback } from '../utils/usage'; -type DisplayMode = 'time' | 'progress' | 'progress-short'; - -function getDisplayMode(item: WidgetItem): DisplayMode { - const mode = item.metadata?.display; - if (mode === 'progress' || mode === 'progress-short') { - return mode; - } - return 'time'; -} - -function isInverted(item: WidgetItem): boolean { - return item.metadata?.invert === 'true'; -} +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageInverted, + isUsageProgressMode, + toggleUsageInverted +} from './shared/usage-display'; function makeTimerProgressBar(percent: number, width: number): string { const clampedPercent = Math.max(0, Math.min(100, percent)); @@ -41,81 +38,38 @@ export class ResetTimerWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const mode = getDisplayMode(item); - const modifiers: string[] = []; - - if (mode === 'progress') { - modifiers.push('progress bar'); - } else if (mode === 'progress-short') { - modifiers.push('short bar'); - } - - if (isInverted(item)) { - modifiers.push('inverted'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getUsageDisplayModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - const currentMode = getDisplayMode(item); - let nextMode: DisplayMode; - - if (currentMode === 'time') { - nextMode = 'progress'; - } else if (currentMode === 'progress') { - nextMode = 'progress-short'; - } else { - nextMode = 'time'; - } - - const nextMetadata: Record = { - ...(item.metadata ?? {}), - display: nextMode - }; - - if (nextMode === 'time') { - delete nextMetadata.invert; - } - - return { - ...item, - metadata: nextMetadata - }; + return cycleUsageDisplayMode(item); } if (action === 'toggle-invert') { - return { - ...item, - metadata: { - ...item.metadata, - invert: (!isInverted(item)).toString() - } - }; + return toggleUsageInverted(item); } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getDisplayMode(item); - const inverted = isInverted(item); + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; - const prefix = item.rawValue ? '' : 'Reset '; - if (displayMode === 'progress' || displayMode === 'progress-short') { - const barWidth = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } - return item.rawValue ? '4hr 30m' : 'Reset: 4hr 30m'; + return formatRawOrLabeledValue(item, 'Reset: ', '4hr 30m'); } const usageData = fetchUsageData(); @@ -129,21 +83,16 @@ export class ResetTimerWidget implements Widget { return null; } - if (displayMode === 'progress' || displayMode === 'progress-short') { - const barWidth = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); const percentage = percent.toFixed(1); - - if (item.rawValue) { - return `[${progressBar}] ${percentage}%`; - } - - return `Reset [${progressBar}] ${percentage}%`; + return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`); } const remainingTime = formatUsageDuration(window.remainingMs); - return item.rawValue ? remainingTime : `Reset: ${remainingTime}`; + return formatRawOrLabeledValue(item, 'Reset: ', remainingTime); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 37a51f8..a79a007 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -12,19 +12,16 @@ import { makeUsageProgressBar } from '../utils/usage'; -type DisplayMode = 'time' | 'progress' | 'progress-short'; - -function getDisplayMode(item: WidgetItem): DisplayMode { - const mode = item.metadata?.display; - if (mode === 'progress' || mode === 'progress-short') { - return mode; - } - return 'time'; -} - -function isInverted(item: WidgetItem): boolean { - return item.metadata?.invert === 'true'; -} +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageInverted, + isUsageProgressMode, + toggleUsageInverted +} from './shared/usage-display'; export class SessionUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } @@ -33,81 +30,39 @@ export class SessionUsageWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const mode = getDisplayMode(item); - const modifiers: string[] = []; - - if (mode === 'progress') { - modifiers.push('progress bar'); - } else if (mode === 'progress-short') { - modifiers.push('short bar'); - } - - if (isInverted(item)) { - modifiers.push('inverted'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getUsageDisplayModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - const currentMode = getDisplayMode(item); - let nextMode: DisplayMode; - - if (currentMode === 'time') { - nextMode = 'progress'; - } else if (currentMode === 'progress') { - nextMode = 'progress-short'; - } else { - nextMode = 'time'; - } - - const nextMetadata: Record = { - ...(item.metadata ?? {}), - display: nextMode - }; - - if (nextMode === 'time') { - delete nextMetadata.invert; - } - - return { - ...item, - metadata: nextMetadata - }; + return cycleUsageDisplayMode(item); } if (action === 'toggle-invert') { - return { - ...item, - metadata: { - ...item.metadata, - invert: (!isInverted(item)).toString() - } - }; + return toggleUsageInverted(item); } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getDisplayMode(item); - const inverted = isInverted(item); + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); if (context.isPreview) { const previewPercent = 20; const renderedPercent = inverted ? 100 - previewPercent : previewPercent; - if (displayMode === 'progress' || displayMode === 'progress-short') { - const width = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; - return item.rawValue ? progressDisplay : `Session: ${progressDisplay}`; + return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } - return item.rawValue ? `${previewPercent.toFixed(1)}%` : `Session: ${previewPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Session: ', `${previewPercent.toFixed(1)}%`); } const data = fetchUsageData(); @@ -117,14 +72,14 @@ export class SessionUsageWidget implements Widget { return null; const percent = Math.max(0, Math.min(100, data.sessionUsage)); - if (displayMode === 'progress' || displayMode === 'progress-short') { - const width = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); const renderedPercent = inverted ? 100 - percent : percent; const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; - return item.rawValue ? progressDisplay : `Session: ${progressDisplay}`; + return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } - return item.rawValue ? `${percent.toFixed(1)}%` : `Session: ${percent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Session: ', `${percent.toFixed(1)}%`); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index 38d0911..1a66aba 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -7,6 +7,8 @@ import type { } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class TokensCachedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows cached token count for the current session'; } @@ -18,11 +20,11 @@ export class TokensCachedWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? '12k' : 'Cached: 12k'; + return formatRawOrLabeledValue(item, 'Cached: ', '12k'); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.cachedTokens) : `Cached: ${formatTokens(context.tokenMetrics.cachedTokens)}`; + return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); } return null; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 8103cd7..43f30b0 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -8,6 +8,8 @@ import type { import { getContextWindowInputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class TokensInputWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows input token count for the current session'; } @@ -19,16 +21,16 @@ export class TokensInputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? '15.2k' : 'In: 15.2k'; + return formatRawOrLabeledValue(item, 'In: ', '15.2k'); } const inputTotalTokens = getContextWindowInputTotalTokens(context.data); if (inputTotalTokens !== null) { - return item.rawValue ? formatTokens(inputTotalTokens) : `In: ${formatTokens(inputTotalTokens)}`; + return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.inputTokens) : `In: ${formatTokens(context.tokenMetrics.inputTokens)}`; + return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens)); } return null; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 9ba40e9..b317b15 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -8,6 +8,8 @@ import type { import { getContextWindowOutputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class TokensOutputWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Shows output token count for the current session'; } @@ -19,16 +21,16 @@ export class TokensOutputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? '3.4k' : 'Out: 3.4k'; + return formatRawOrLabeledValue(item, 'Out: ', '3.4k'); } const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); if (outputTotalTokens !== null) { - return item.rawValue ? formatTokens(outputTotalTokens) : `Out: ${formatTokens(outputTotalTokens)}`; + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.outputTokens) : `Out: ${formatTokens(context.tokenMetrics.outputTokens)}`; + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens)); } return null; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 59abecd..37659bc 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -7,6 +7,8 @@ import type { } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class TokensTotalWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows total token count (input + output + cache) for the current session'; } @@ -18,11 +20,11 @@ export class TokensTotalWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? '30.6k' : 'Total: 30.6k'; + return formatRawOrLabeledValue(item, 'Total: ', '30.6k'); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.totalTokens) : `Total: ${formatTokens(context.tokenMetrics.totalTokens)}`; + return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); } return null; } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 0bf7d94..9085e36 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -12,19 +12,16 @@ import { makeUsageProgressBar } from '../utils/usage'; -type DisplayMode = 'time' | 'progress' | 'progress-short'; - -function getDisplayMode(item: WidgetItem): DisplayMode { - const mode = item.metadata?.display; - if (mode === 'progress' || mode === 'progress-short') { - return mode; - } - return 'time'; -} - -function isInverted(item: WidgetItem): boolean { - return item.metadata?.invert === 'true'; -} +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageInverted, + isUsageProgressMode, + toggleUsageInverted +} from './shared/usage-display'; export class WeeklyUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } @@ -33,81 +30,39 @@ export class WeeklyUsageWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const mode = getDisplayMode(item); - const modifiers: string[] = []; - - if (mode === 'progress') { - modifiers.push('progress bar'); - } else if (mode === 'progress-short') { - modifiers.push('short bar'); - } - - if (isInverted(item)) { - modifiers.push('inverted'); - } - return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: getUsageDisplayModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - const currentMode = getDisplayMode(item); - let nextMode: DisplayMode; - - if (currentMode === 'time') { - nextMode = 'progress'; - } else if (currentMode === 'progress') { - nextMode = 'progress-short'; - } else { - nextMode = 'time'; - } - - const nextMetadata: Record = { - ...(item.metadata ?? {}), - display: nextMode - }; - - if (nextMode === 'time') { - delete nextMetadata.invert; - } - - return { - ...item, - metadata: nextMetadata - }; + return cycleUsageDisplayMode(item); } if (action === 'toggle-invert') { - return { - ...item, - metadata: { - ...item.metadata, - invert: (!isInverted(item)).toString() - } - }; + return toggleUsageInverted(item); } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getDisplayMode(item); - const inverted = isInverted(item); + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); if (context.isPreview) { const previewPercent = 12; const renderedPercent = inverted ? 100 - previewPercent : previewPercent; - if (displayMode === 'progress' || displayMode === 'progress-short') { - const width = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; - return item.rawValue ? progressDisplay : `Weekly: ${progressDisplay}`; + return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } - return item.rawValue ? `${previewPercent.toFixed(1)}%` : `Weekly: ${previewPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Weekly: ', `${previewPercent.toFixed(1)}%`); } const data = fetchUsageData(); @@ -117,14 +72,14 @@ export class WeeklyUsageWidget implements Widget { return null; const percent = Math.max(0, Math.min(100, data.weeklyUsage)); - if (displayMode === 'progress' || displayMode === 'progress-short') { - const width = displayMode === 'progress' ? 32 : 16; + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); const renderedPercent = inverted ? 100 - percent : percent; const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`; - return item.rawValue ? progressDisplay : `Weekly: ${progressDisplay}`; + return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } - return item.rawValue ? `${percent.toFixed(1)}%` : `Weekly: ${percent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, 'Weekly: ', `${percent.toFixed(1)}%`); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/__tests__/BlockTimer.test.ts b/src/widgets/__tests__/BlockTimer.test.ts index 2d82128..f6dae36 100644 --- a/src/widgets/__tests__/BlockTimer.test.ts +++ b/src/widgets/__tests__/BlockTimer.test.ts @@ -129,4 +129,33 @@ describe('BlockTimerWidget', () => { expect(updated?.metadata?.display).toBe('time'); expect(updated?.metadata?.invert).toBeUndefined(); }); + + it('cycles display modes in the expected order', () => { + const widget = new BlockTimerWidget(); + const base: WidgetItem = { id: 'block', type: 'block-timer' }; + + const first = widget.handleEditorAction('toggle-progress', base); + const second = widget.handleEditorAction('toggle-progress', first ?? base); + const third = widget.handleEditorAction('toggle-progress', second ?? base); + + expect(first?.metadata?.display).toBe('progress'); + expect(second?.metadata?.display).toBe('progress-short'); + expect(third?.metadata?.display).toBe('time'); + }); + + it('toggles invert metadata and shows editor modifiers', () => { + const widget = new BlockTimerWidget(); + const base: WidgetItem = { id: 'block', type: 'block-timer' }; + + const inverted = widget.handleEditorAction('toggle-invert', base); + const cleared = widget.handleEditorAction('toggle-invert', inverted ?? base); + + expect(inverted?.metadata?.invert).toBe('true'); + expect(cleared?.metadata?.invert).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { display: 'progress', invert: 'true' } + }).modifierText).toBe('(progress bar, inverted)'); + }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 14a4f0d..ddc83fe 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -34,6 +34,25 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f } describe('ContextPercentageWidget', () => { + it('toggles inverse metadata and editor modifier', () => { + const widget = new ContextPercentageWidget(); + const base: WidgetItem = { + id: 'context-percentage', + type: 'context-percentage' + }; + + const inverted = widget.handleEditorAction('toggle-inverse', base); + const cleared = widget.handleEditorAction('toggle-inverse', inverted ?? base); + + expect(inverted?.metadata?.inverse).toBe('true'); + expect(cleared?.metadata?.inverse).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { inverse: 'true' } + }).modifierText).toBe('(remaining)'); + }); + it('prefers context_window percentage over token metrics when both exist', () => { const widget = new ContextPercentageWidget(); const item: WidgetItem = { diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index d55ff25..2121f39 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -34,6 +34,25 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f } describe('ContextPercentageUsableWidget', () => { + it('toggles inverse metadata and editor modifier', () => { + const widget = new ContextPercentageUsableWidget(); + const base: WidgetItem = { + id: 'context-percentage-usable', + type: 'context-percentage-usable' + }; + + const inverted = widget.handleEditorAction('toggle-inverse', base); + const cleared = widget.handleEditorAction('toggle-inverse', inverted ?? base); + + expect(inverted?.metadata?.inverse).toBe('true'); + expect(cleared?.metadata?.inverse).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { inverse: 'true' } + }).modifierText).toBe('(remaining)'); + }); + it('prefers context_window usage over token metrics when both exist', () => { const widget = new ContextPercentageUsableWidget(); const item: WidgetItem = { diff --git a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts new file mode 100644 index 0000000..b90ef0c --- /dev/null +++ b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts @@ -0,0 +1,58 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../types'; +import { GitBranchWidget } from '../GitBranch'; +import { GitChangesWidget } from '../GitChanges'; +import { GitDeletionsWidget } from '../GitDeletions'; +import { GitInsertionsWidget } from '../GitInsertions'; +import { GitRootDirWidget } from '../GitRootDir'; +import { GitWorktreeWidget } from '../GitWorktree'; + +type GitWidget = Widget & { + getCustomKeybinds: () => CustomKeybind[]; + handleEditorAction: (action: string, item: WidgetItem) => WidgetItem | null; +}; + +const cases: { name: string; itemType: string; widget: GitWidget }[] = [ + { name: 'GitBranchWidget', itemType: 'git-branch', widget: new GitBranchWidget() }, + { name: 'GitChangesWidget', itemType: 'git-changes', widget: new GitChangesWidget() }, + { name: 'GitInsertionsWidget', itemType: 'git-insertions', widget: new GitInsertionsWidget() }, + { name: 'GitDeletionsWidget', itemType: 'git-deletions', widget: new GitDeletionsWidget() }, + { name: 'GitRootDirWidget', itemType: 'git-root-dir', widget: new GitRootDirWidget() }, + { name: 'GitWorktreeWidget', itemType: 'git-worktree', widget: new GitWorktreeWidget() } +]; + +describe('Git widget shared behavior', () => { + it.each(cases)('$name should expose hide-no-git keybind', ({ widget }) => { + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } + ]); + }); + + it.each(cases)('$name should toggle hideNoGit metadata', ({ widget, itemType }) => { + const base: WidgetItem = { id: itemType, type: itemType }; + const toggledOn = widget.handleEditorAction('toggle-nogit', base); + const toggledOff = widget.handleEditorAction('toggle-nogit', toggledOn ?? base); + + expect(toggledOn?.metadata?.hideNoGit).toBe('true'); + expect(toggledOff?.metadata?.hideNoGit).toBe('false'); + }); + + it.each(cases)('$name should show hide-no-git modifier in editor display', ({ widget, itemType }) => { + const display = widget.getEditorDisplay({ + id: itemType, + type: itemType, + metadata: { hideNoGit: 'true' } + }); + + expect(display.modifierText).toBe('(hide \'no git\')'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/ResetTimer.test.ts b/src/widgets/__tests__/ResetTimer.test.ts index dacf600..1099652 100644 --- a/src/widgets/__tests__/ResetTimer.test.ts +++ b/src/widgets/__tests__/ResetTimer.test.ts @@ -142,4 +142,33 @@ describe('ResetTimerWidget', () => { expect(updated?.metadata?.display).toBe('time'); expect(updated?.metadata?.invert).toBeUndefined(); }); + + it('cycles display modes in the expected order', () => { + const widget = new ResetTimerWidget(); + const base: WidgetItem = { id: 'reset', type: 'reset-timer' }; + + const first = widget.handleEditorAction('toggle-progress', base); + const second = widget.handleEditorAction('toggle-progress', first ?? base); + const third = widget.handleEditorAction('toggle-progress', second ?? base); + + expect(first?.metadata?.display).toBe('progress'); + expect(second?.metadata?.display).toBe('progress-short'); + expect(third?.metadata?.display).toBe('time'); + }); + + it('toggles invert metadata and shows editor modifiers', () => { + const widget = new ResetTimerWidget(); + const base: WidgetItem = { id: 'reset', type: 'reset-timer' }; + + const inverted = widget.handleEditorAction('toggle-invert', base); + const cleared = widget.handleEditorAction('toggle-invert', inverted ?? base); + + expect(inverted?.metadata?.invert).toBe('true'); + expect(cleared?.metadata?.invert).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { display: 'progress-short', invert: 'true' } + }).modifierText).toBe('(short bar, inverted)'); + }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/SessionUsage.test.ts b/src/widgets/__tests__/SessionUsage.test.ts index b286da0..1b80d85 100644 --- a/src/widgets/__tests__/SessionUsage.test.ts +++ b/src/widgets/__tests__/SessionUsage.test.ts @@ -112,4 +112,33 @@ describe('SessionUsageWidget', () => { expect(updated?.metadata?.display).toBe('time'); expect(updated?.metadata?.invert).toBeUndefined(); }); + + it('cycles display modes in the expected order', () => { + const widget = new SessionUsageWidget(); + const base: WidgetItem = { id: 'session', type: 'session-usage' }; + + const first = widget.handleEditorAction('toggle-progress', base); + const second = widget.handleEditorAction('toggle-progress', first ?? base); + const third = widget.handleEditorAction('toggle-progress', second ?? base); + + expect(first?.metadata?.display).toBe('progress'); + expect(second?.metadata?.display).toBe('progress-short'); + expect(third?.metadata?.display).toBe('time'); + }); + + it('toggles invert metadata and shows editor modifiers', () => { + const widget = new SessionUsageWidget(); + const base: WidgetItem = { id: 'session', type: 'session-usage' }; + + const inverted = widget.handleEditorAction('toggle-invert', base); + const cleared = widget.handleEditorAction('toggle-invert', inverted ?? base); + + expect(inverted?.metadata?.invert).toBe('true'); + expect(cleared?.metadata?.invert).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { display: 'progress-short', invert: 'true' } + }).modifierText).toBe('(short bar, inverted)'); + }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index 9b73263..d5ac241 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -1,18 +1,34 @@ import { describe, expect, - it + it, + vi } from 'vitest'; import type { RenderContext } from '../../types'; import { DEFAULT_SETTINGS } from '../../types/Settings'; -import { TokensCachedWidget } from '../TokensCached'; -import { TokensInputWidget } from '../TokensInput'; -import { TokensOutputWidget } from '../TokensOutput'; -import { TokensTotalWidget } from '../TokensTotal'; + +vi.mock('../../utils/renderer', () => ({ formatTokens: vi.fn((value: number) => `fmt:${value}`) })); + +async function loadWidgets() { + const [{ TokensInputWidget }, { TokensOutputWidget }, { TokensCachedWidget }, { TokensTotalWidget }] = await Promise.all([ + import('../TokensInput'), + import('../TokensOutput'), + import('../TokensCached'), + import('../TokensTotal') + ]); + + return { + TokensCachedWidget, + TokensInputWidget, + TokensOutputWidget, + TokensTotalWidget + }; +} describe('Token widgets', () => { - it('use context_window values for input/output and tokenMetrics totals for cached/total', () => { + it('use context_window values for input/output and tokenMetrics totals for cached/total', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); const context: RenderContext = { data: { context_window: { @@ -35,13 +51,14 @@ describe('Token widgets', () => { } }; - expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 1.1k'); - expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: 2.2k'); - expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 10.0k'); - expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 10.0k'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: fmt:1111'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: fmt:2222'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: fmt:9999'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: fmt:9999'); }); - it('fall back to token metrics when context_window data is missing', () => { + it('fall back to token metrics when context_window data is missing', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); const context: RenderContext = { tokenMetrics: { inputTokens: 1200, @@ -52,9 +69,53 @@ describe('Token widgets', () => { } }; - expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 1.2k'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: fmt:1200'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: fmt:3400'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: fmt:560'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: fmt:5160'); + }); + + it('renders raw values without labels for all token widgets', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { + data: { + context_window: { + total_input_tokens: 1111, + total_output_tokens: 2222, + current_usage: { + input_tokens: 300, + output_tokens: 400, + cache_creation_input_tokens: 50, + cache_read_input_tokens: 25 + } + } + }, + tokenMetrics: { + inputTokens: 1200, + outputTokens: 3400, + cachedTokens: 560, + totalTokens: 5160, + contextLength: 20000 + } + }; + + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:1111'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:2222'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:560'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:5160'); + }); + + it('renders expected preview labels and raw values for all token widgets', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { isPreview: true }; + + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 15.2k'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('15.2k'); expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: 3.4k'); - expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 560'); - expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 5.2k'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('3.4k'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 12k'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('12k'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 30.6k'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('30.6k'); }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/WeeklyUsage.test.ts b/src/widgets/__tests__/WeeklyUsage.test.ts index db01fc7..8a52ec0 100644 --- a/src/widgets/__tests__/WeeklyUsage.test.ts +++ b/src/widgets/__tests__/WeeklyUsage.test.ts @@ -112,4 +112,33 @@ describe('WeeklyUsageWidget', () => { expect(updated?.metadata?.display).toBe('time'); expect(updated?.metadata?.invert).toBeUndefined(); }); + + it('cycles display modes in the expected order', () => { + const widget = new WeeklyUsageWidget(); + const base: WidgetItem = { id: 'weekly', type: 'weekly-usage' }; + + const first = widget.handleEditorAction('toggle-progress', base); + const second = widget.handleEditorAction('toggle-progress', first ?? base); + const third = widget.handleEditorAction('toggle-progress', second ?? base); + + expect(first?.metadata?.display).toBe('progress'); + expect(second?.metadata?.display).toBe('progress-short'); + expect(third?.metadata?.display).toBe('time'); + }); + + it('toggles invert metadata and shows editor modifiers', () => { + const widget = new WeeklyUsageWidget(); + const base: WidgetItem = { id: 'weekly', type: 'weekly-usage' }; + + const inverted = widget.handleEditorAction('toggle-invert', base); + const cleared = widget.handleEditorAction('toggle-invert', inverted ?? base); + + expect(inverted?.metadata?.invert).toBe('true'); + expect(cleared?.metadata?.invert).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { display: 'progress', invert: 'true' } + }).modifierText).toBe('(progress bar, inverted)'); + }); }); \ No newline at end of file diff --git a/src/widgets/shared/context-inverse.ts b/src/widgets/shared/context-inverse.ts new file mode 100644 index 0000000..cd0bdbe --- /dev/null +++ b/src/widgets/shared/context-inverse.ts @@ -0,0 +1,26 @@ +import type { WidgetItem } from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const INVERSE_KEY = 'inverse'; +const TOGGLE_INVERSE_ACTION = 'toggle-inverse'; + +export function isContextInverse(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, INVERSE_KEY); +} + +export function getContextInverseModifierText(item: WidgetItem): string | undefined { + return makeModifierText(isContextInverse(item) ? ['remaining'] : []); +} + +export function handleContextInverseAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== TOGGLE_INVERSE_ACTION) { + return null; + } + + return toggleMetadataFlag(item, INVERSE_KEY); +} \ No newline at end of file diff --git a/src/widgets/shared/editor-display.ts b/src/widgets/shared/editor-display.ts new file mode 100644 index 0000000..7c33a7d --- /dev/null +++ b/src/widgets/shared/editor-display.ts @@ -0,0 +1,3 @@ +export function makeModifierText(modifiers: string[]): string | undefined { + return modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined; +} \ No newline at end of file diff --git a/src/widgets/shared/git-no-git.ts b/src/widgets/shared/git-no-git.ts new file mode 100644 index 0000000..826df2e --- /dev/null +++ b/src/widgets/shared/git-no-git.ts @@ -0,0 +1,39 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const HIDE_NO_GIT_KEY = 'hideNoGit'; +const TOGGLE_NO_GIT_ACTION = 'toggle-nogit'; + +const HIDE_NO_GIT_KEYBIND: CustomKeybind = { + key: 'h', + label: '(h)ide \'no git\' message', + action: TOGGLE_NO_GIT_ACTION +}; + +export function isHideNoGitEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_NO_GIT_KEY); +} + +export function getHideNoGitModifierText(item: WidgetItem): string | undefined { + return makeModifierText(isHideNoGitEnabled(item) ? ['hide \'no git\''] : []); +} + +export function handleToggleNoGitAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== TOGGLE_NO_GIT_ACTION) { + return null; + } + + return toggleMetadataFlag(item, HIDE_NO_GIT_KEY); +} + +export function getHideNoGitKeybinds(): CustomKeybind[] { + return [HIDE_NO_GIT_KEYBIND]; +} \ No newline at end of file diff --git a/src/widgets/shared/metadata.ts b/src/widgets/shared/metadata.ts new file mode 100644 index 0000000..4a469ca --- /dev/null +++ b/src/widgets/shared/metadata.ts @@ -0,0 +1,15 @@ +import type { WidgetItem } from '../../types/Widget'; + +export function isMetadataFlagEnabled(item: WidgetItem, key: string): boolean { + return item.metadata?.[key] === 'true'; +} + +export function toggleMetadataFlag(item: WidgetItem, key: string): WidgetItem { + return { + ...item, + metadata: { + ...item.metadata, + [key]: (!isMetadataFlagEnabled(item, key)).toString() + } + }; +} \ No newline at end of file diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts new file mode 100644 index 0000000..fb647d0 --- /dev/null +++ b/src/widgets/shared/raw-or-labeled.ts @@ -0,0 +1,5 @@ +import type { WidgetItem } from '../../types/Widget'; + +export function formatRawOrLabeledValue(item: WidgetItem, labelPrefix: string, value: string): string { + return item.rawValue ? value : `${labelPrefix}${value}`; +} \ No newline at end of file diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts new file mode 100644 index 0000000..5d4b2d1 --- /dev/null +++ b/src/widgets/shared/usage-display.ts @@ -0,0 +1,73 @@ +import type { WidgetItem } from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +export type UsageDisplayMode = 'time' | 'progress' | 'progress-short'; + +export function getUsageDisplayMode(item: WidgetItem): UsageDisplayMode { + const mode = item.metadata?.display; + if (mode === 'progress' || mode === 'progress-short') { + return mode; + } + return 'time'; +} + +export function isUsageProgressMode(mode: UsageDisplayMode): boolean { + return mode === 'progress' || mode === 'progress-short'; +} + +export function getUsageProgressBarWidth(mode: UsageDisplayMode): number { + return mode === 'progress' ? 32 : 16; +} + +export function isUsageInverted(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, 'invert'); +} + +export function getUsageDisplayModifierText(item: WidgetItem): string | undefined { + const mode = getUsageDisplayMode(item); + const modifiers: string[] = []; + + if (mode === 'progress') { + modifiers.push('progress bar'); + } else if (mode === 'progress-short') { + modifiers.push('short bar'); + } + + if (isUsageInverted(item)) { + modifiers.push('inverted'); + } + + return makeModifierText(modifiers); +} + +export function cycleUsageDisplayMode(item: WidgetItem): WidgetItem { + const currentMode = getUsageDisplayMode(item); + const nextMode: UsageDisplayMode = currentMode === 'time' + ? 'progress' + : currentMode === 'progress' + ? 'progress-short' + : 'time'; + + const nextMetadata: Record = { + ...(item.metadata ?? {}), + display: nextMode + }; + + if (nextMode === 'time') { + delete nextMetadata.invert; + } + + return { + ...item, + metadata: nextMetadata + }; +} + +export function toggleUsageInverted(item: WidgetItem): WidgetItem { + return toggleMetadataFlag(item, 'invert'); +} \ No newline at end of file