refactor: implement phase 3 widget pattern extraction with tests

This commit is contained in:
Matthew Breedlove
2026-03-03 18:24:39 -05:00
parent 9aea4c1265
commit da7eaffe03
30 changed files with 654 additions and 496 deletions
+27 -78
View File
@@ -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<string, string> = {
...(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[] {
+13 -23
View File
@@ -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;
+13 -23
View File
@@ -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;
}
+11 -23
View File
@@ -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; }
+11 -23
View File
@@ -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; }
+11 -23
View File
@@ -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; }
+11 -23
View File
@@ -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; }
+11 -23
View File
@@ -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; }
+11 -23
View File
@@ -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; }
+23 -74
View File
@@ -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<string, string> = {
...(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[] {
+23 -68
View File
@@ -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<string, string> = {
...(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[] {
+4 -2
View File
@@ -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;
}
+5 -3
View File
@@ -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;
}
+5 -3
View File
@@ -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;
}
+4 -2
View File
@@ -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;
}
+23 -68
View File
@@ -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<string, string> = {
...(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[] {
+29
View File
@@ -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)');
});
});
@@ -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 = {
@@ -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 = {
@@ -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\')');
});
});
+29
View File
@@ -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)');
});
});
@@ -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)');
});
});
+75 -14
View File
@@ -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');
});
});
+29
View File
@@ -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)');
});
});
+26
View File
@@ -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);
}
+3
View File
@@ -0,0 +1,3 @@
export function makeModifierText(modifiers: string[]): string | undefined {
return modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined;
}
+39
View File
@@ -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];
}
+15
View File
@@ -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()
}
};
}
+5
View File
@@ -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}`;
}
+73
View File
@@ -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<string, string> = {
...(item.metadata ?? {}),
display: nextMode
};
if (nextMode === 'time') {
delete nextMetadata.invert;
}
return {
...item,
metadata: nextMetadata
};
}
export function toggleUsageInverted(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'invert');
}