diff --git a/docs/USAGE.md b/docs/USAGE.md index 62cd88bcf..b8d9631f9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -226,6 +226,8 @@ Some widgets support "raw value" mode which displays just the value without a la - Normal: `Block: 3hr 45m` → Raw: `3hr 45m` - Normal: `Ctx: 18.6k` → Raw: `18.6k` +To keep a label but change it, select the widget and press `b` (`la(b)el…`) instead. The label is replaced verbatim, so include any trailing space or colon yourself (e.g. `M ` renders `M Claude 3.5 Sonnet`); an empty label drops it, and Tab in the editor resets it to the default. Widgets whose label changes with their mode (e.g. `Ctx Used:`/`Ctx Left:`, or `Block:` vs `Block ` before a progress bar) use the one custom label in every mode, even one that matches the current mode's default. The editor row shows `(label: "M ")` while a custom label is set; `b` is hidden while raw value is on, since the label isn't rendered then. + ## Number Formatting Numeric widgets support three display styles without changing their underlying values: @@ -249,6 +251,7 @@ Common controls in the line editor: - `c` clear the current line - `Space` cycle a manual separator character - `r` toggle raw value (supported widgets) +- `b` edit the label shown before the value when raw value is off (labeled widgets) - `.` cycle precise/compact/whole number formatting (supported widgets) - `m` cycle merge mode (`off` → `merge` → `merge no padding`) - `x` exclude the selected widget and the rest of its line from shared Powerline column widths (shown only when Powerline auto-alignment is enabled) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 470ee1486..fb83d2eb9 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -31,9 +31,16 @@ import { getHideKeybind, getHideModifierText } from '../../widgets/shared/hideable'; +import { + EDIT_LABEL_ACTION, + clearLabel, + getLabelKeybind, + getLabelModifierText +} from '../../widgets/shared/raw-or-labeled'; import { ConfirmDialog } from './ConfirmDialog'; import { HideStatesEditor } from './HideStatesEditor'; +import { LabelEditor } from './LabelEditor'; import { handleMoveInputMode, handleNormalInputMode, @@ -132,6 +139,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB keybinds.push(getHideKeybind()); } + // The label only shows when raw value is off, so the editor is offered + // only then. Like 'h', widgets must leave this key unbound. + if (widgetImpl.getLabelPrefix && !widget.rawValue) { + keybinds.push(getLabelKeybind()); + } + return keybinds; }; @@ -162,7 +175,10 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const currentWidget = widgets[selectedIndex]; if (currentWidget) { const newWidgets = [...widgets]; - newWidgets[selectedIndex] = { ...currentWidget, type: selectedType }; + // Other metadata carries over, but a label names the old widget's value + newWidgets[selectedIndex] = currentWidget.type === selectedType + ? currentWidget + : { ...clearLabel(currentWidget), type: selectedType }; onUpdate(newWidgets); } } else { @@ -350,6 +366,17 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ); } + if (customEditorWidget?.action === EDIT_LABEL_ACTION && customEditorWidget.impl.getLabelPrefix) { + return ( + + ); + } + // If custom editor is active, render it instead of the normal UI if (customEditorWidget?.impl.renderEditor) { return customEditorWidget.impl.renderEditor({ @@ -582,6 +609,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? getNumberFormatModifierText(widget) : undefined; const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined; + const labelModifierText = widgetImpl?.getLabelPrefix && !widget.rawValue ? getLabelModifierText(widget) : undefined; return ( @@ -611,6 +639,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {hideModifierText} )} + {labelModifierText && ( + + {' '} + {labelModifierText} + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx new file mode 100644 index 000000000..2af68d89d --- /dev/null +++ b/src/tui/components/LabelEditor.tsx @@ -0,0 +1,53 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React from 'react'; + +import type { WidgetItem } from '../../types/Widget'; +import { + clearLabel, + getLabel, + setLabel +} from '../../widgets/shared/raw-or-labeled'; +import { useTextCursor } from '../../widgets/shared/text-cursor'; + +export interface LabelEditorProps { + widget: WidgetItem; + defaultLabel: string; + onComplete: (updatedWidget: WidgetItem) => void; + onCancel: () => void; +} + +export const LabelEditor: React.FC = ({ widget, defaultLabel, onComplete, onCancel }) => { + const { getText, display, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); + + useInput((input, key) => { + if (key.return) { + onComplete(setLabel(widget, getText())); + } else if (key.escape) { + onCancel(); + } else if (key.tab) { + onComplete(clearLabel(widget)); + } else { + handleInput(input, key); + } + }); + + // Quoted so trailing spaces, which usually separate the label from the + // value, stay visible. One Text, because Ink measures a toned or joined + // emoji as several columns and a sibling Text would overwrite its end. + return ( + + Label + ←→ move cursor, Ctrl+←→ jump to start/end, Tab reset to default, Enter save, ESC cancel + + + {`"${display}"`} + {` (default: ${JSON.stringify(defaultLabel)})`} + + + + ); +}; diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index 93546a99e..f3f227752 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -11,6 +11,7 @@ import { import { DEFAULT_SETTINGS } from '../../../types/Settings'; import type { WidgetItem } from '../../../types/Widget'; +import { waitFor } from '../../__tests__/helpers/wait-for-ink'; import { ItemsEditor } from '../ItemsEditor'; class MockTtyStream extends PassThrough { @@ -163,4 +164,259 @@ describe('ItemsEditor', () => { stderr.destroy(); } }); + it('edits a widget label and hides the label keybind in raw value mode', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'model' }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('la(b)el…'); + + stdout.clearOutput(); + stdin.write('b'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('(default: "Model: ")'); + + // Trim "Model: " down to "M", one backspace per keypress + for (const key of Array.from('odel: ', () => '\x7f')) { + stdin.write(key); + await flushInk(); + } + stdin.write(' '); + await flushInk(); + stdout.clearOutput(); + stdin.write('\r'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Model (label: "M ")'); + + stdout.clearOutput(); + stdin.write('r'); + await flushInk(); + const rawOutput = stripAnsi(stdout.getOutput()); + expect(rawOutput).toContain('(raw value)'); + expect(rawOutput).not.toContain('la(b)el…'); + expect(rawOutput).not.toContain('(label:'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it.each([ + { query: 'session cost', name: 'Session Cost', keepsLabel: false }, + { query: 'model', name: 'Model', keepsLabel: true } + ])('preserves the label only when reselecting the same type: $name', async ({ query, name, keepsLabel }) => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'model', metadata: { label: 'M ' } }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Model (label: "M ")'); + + stdin.write('\x1b[C'); + await flushInk(); + for (const char of query) { + stdin.write(char); + await flushInk(); + } + stdout.clearOutput(); + stdin.write('\r'); + await flushInk(); + const output = stripAnsi(stdout.getOutput()); + expect(output).toContain(`1. ${name}`); + if (keepsLabel) { + expect(output).toContain('(label: "M ")'); + } else { + expect(output).not.toContain('(label:'); + } + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('keeps every key typed into the label editor before a re-render', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'model' }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('b'); + await flushInk(); + + // One macrotask apart: separate keypresses, but no re-render between them + stdin.write('\x7f'); + await new Promise(resolve => setImmediate(resolve)); + stdin.write('\x7f'); + await flushInk(); + stdout.clearOutput(); + stdin.write('\r'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Model (label: "Model")'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it.each([ + { type: 'model', openKey: 'b', prompt: '(default: "Model: ")', edit: 'X', expected: '1. Model (label: "Model: X")' }, + { type: 'model', openKey: 'b', prompt: '(default: "Model: ")', edit: '\x7f', expected: '1. Model (label: "Model:")' }, + { type: 'custom-text', openKey: 'e', prompt: 'Enter custom text:', edit: 'X', expected: '1. Custom Text (HelloX)' }, + { type: 'custom-text', openKey: 'e', prompt: 'Enter custom text:', edit: '\x7f', expected: '1. Custom Text (Hell)' } + ])('saves the latest $type edit when Enter arrives before a redraw ($edit)', async ({ type, openKey, prompt, edit, expected }) => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type, customText: 'Hello' }] }), + { stdin, stdout, stderr, debug: true, exitOnCtrlC: false, patchConsole: false } + ); + + try { + await waitFor(() => { + expect(stripAnsi(stdout.getOutput())).toContain('1. '); + }); + stdout.clearOutput(); + stdin.write(openKey); + await waitFor(() => { + expect(stripAnsi(stdout.getOutput())).toContain(prompt); + }); + + stdout.clearOutput(); + stdin.write(edit); + await new Promise(resolve => setImmediate(resolve)); + stdin.write('\r'); + await waitFor(() => { + expect(stripAnsi(stdout.getOutput())).toContain(expected); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('shows a joined emoji label in full beside its default', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'model', metadata: { label: '\u{1F469}\u200D\u{1F4BB} ' } }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdout.clearOutput(); + stdin.write('b'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('"\u{1F469}\u200D\u{1F4BB} " (default: "Model: ")'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('keeps a label equal to the bar-mode default for every mode, and Tab clears it', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'block-timer', metadata: { display: 'progress' } }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('b'); + await flushInk(); + stdout.clearOutput(); + stdin.write('\r'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('(label: "Block ")'); + + stdin.write('b'); + await flushInk(); + stdout.clearOutput(); + stdin.write('\t'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).not.toContain('(label:'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 2c7f52b2e..986407176 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -15,6 +15,7 @@ import { type WidgetCatalogEntry } from '../../../utils/widgets'; import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable'; +import { EDIT_LABEL_ACTION } from '../../../widgets/shared/raw-or-labeled'; export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -487,10 +488,10 @@ export function handleNormalInputMode({ const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind && !key.ctrl) { - // The hide-state checklist is rendered by the items editor for - // every widget that declares hideable states, so it bypasses + // The hide-state checklist and label editor are rendered by the + // items editor for every widget that opts in, so they bypass // widget-level action handling. - if (matchedKeybind.action === EDIT_HIDE_STATES_ACTION) { + if (matchedKeybind.action === EDIT_HIDE_STATES_ACTION || matchedKeybind.action === EDIT_LABEL_ACTION) { setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); return; } diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 338e92800..5f8c6e70f 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -60,6 +60,10 @@ export interface Widget { // Gates the items editor's precision keybind; widgets that omit it are // treated as non-numeric. supportsNumberFormat?(): boolean; + // The label prepended when raw value is off, for the item's current mode. + // Declaring it opts the widget into the items editor's label override, + // which stores the replacement in metadata.label. + getLabelPrefix?(item: WidgetItem): string; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; getNumericValue?(context: RenderContext, item: WidgetItem): number | null; /** diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 84b286dbe..da77b2605 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -9,6 +9,7 @@ import { type Settings } from '../../types/Settings'; import { getHideKeybind } from '../../widgets/shared/hideable'; +import { getLabelKeybind } from '../../widgets/shared/raw-or-labeled'; import { filterWidgetCatalog, getAllWidgetTypes, @@ -152,7 +153,7 @@ describe('legacy widget type aliases', () => { }); }); -describe('hideable state keybind reservation', () => { +describe('shared keybind reservation', () => { // Widgets vary their keybinds by display mode, so an item-free call sees // only one branch. These cover the metadata the usage and timer widgets // branch on, so a bind offered in just one mode still gets caught. @@ -195,6 +196,31 @@ describe('hideable state keybind reservation', () => { } } }); + + it('widgets declaring a label prefix leave the shared label key free in every mode', () => { + const reservedKey = getLabelKeybind().key; + const settings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + const runtimeTypes = getAllWidgetTypes(settings).filter( + type => type !== 'separator' && type !== 'flex-separator' + ); + + for (const type of runtimeTypes) { + const widget = getWidget(type); + if (!widget?.getLabelPrefix) { + continue; + } + + for (const metadata of KEYBIND_MODE_PROBES) { + const item = metadata === undefined ? undefined : { id: '1', type, metadata }; + const keys = (widget.getCustomKeybinds?.(item) ?? []).map(keybind => keybind.key); + expect(`${type}/${JSON.stringify(metadata)} binds ${keys.includes(reservedKey) ? reservedKey : 'nothing reserved'}`) + .toBe(`${type}/${JSON.stringify(metadata)} binds nothing reserved`); + } + } + }); }); describe('widget catalog filtering', () => { @@ -342,3 +368,37 @@ describe('getMatchSegments', () => { expect(segments[0]).toEqual({ text: 'Git', matched: true }); }); }); + +describe('label prefix', () => { + const LABEL_MODE_PROBES: Record[] = [ + {}, + { absolute: 'true' }, + { display: 'progress' }, + { display: 'progress-short' }, + { display: 'slider' }, + { display: 'slider-only' }, + { inverse: 'true' } + ]; + + it('widgets render the label their prefix declares in every mode', () => { + const settings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + + for (const type of getAllWidgetTypes(settings)) { + const widget = getWidget(type); + if (!widget?.getLabelPrefix) { + continue; + } + + for (const metadata of LABEL_MODE_PROBES) { + const item = { id: '1', type, metadata }; + const label = widget.getLabelPrefix(item); + const rendered = widget.render(item, { isPreview: true }, settings) ?? ''; + expect(`${type}/${JSON.stringify(metadata)}: ${rendered.includes(label) ? 'has' : 'lacks'} "${label}"`) + .toBe(`${type}/${JSON.stringify(metadata)}: has "${label}"`); + } + } + }); +}); diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 1a9782adf..15575c88d 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -54,6 +54,9 @@ import { toggleUsageInverted } from './shared/usage-display'; +const LABEL = 'Reset: '; +const BAR_LABEL = 'Reset '; + const BLOCK_RESET_PREVIEW_AT = '2026-03-12T08:30:00.000Z'; const USAGE_TIMER_LOADING_MESSAGE = '[Loading]'; @@ -62,6 +65,10 @@ export class BlockResetTimerWidget implements Widget { getDescription(): string { return 'Shows time remaining until current 5hr block reset window'; } getDisplayName(): string { return 'Block Reset Timer'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -111,7 +118,7 @@ export class BlockResetTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -119,7 +126,7 @@ export class BlockResetTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(previewPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } if (dateMode) { @@ -130,10 +137,10 @@ export class BlockResetTimerWidget implements Widget { getUsageLocale(item), isUsage12HourClock(item) ); - return formatRawOrLabeledValue(item, 'Reset: ', resetAt ?? (compact ? '03-12 08:30Z' : '2026-03-12 08:30 UTC')); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt ?? (compact ? '03-12 08:30Z' : '2026-03-12 08:30 UTC')); } - return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), compact ? '4h30m' : '4hr 30m'); } const usageData = context.usageData ?? {}; @@ -148,14 +155,14 @@ export class BlockResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, 'Reset: ', USAGE_TIMER_LOADING_MESSAGE); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), USAGE_TIMER_LOADING_MESSAGE); } if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${formatPercent(percent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -164,7 +171,7 @@ export class BlockResetTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(percent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } if (dateMode) { @@ -172,12 +179,12 @@ export class BlockResetTimerWidget implements Widget { const locale = getUsageLocale(item); const resetAt = formatUsageResetAt(usageData.sessionResetAt, compact, timezone, locale, isUsage12HourClock(item)); if (resetAt) { - return formatRawOrLabeledValue(item, 'Reset: ', resetAt); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact); - return formatRawOrLabeledValue(item, 'Reset: ', remainingTime); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), remainingTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 59beaace0..af07aa185 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -34,6 +34,9 @@ import { toggleUsageInverted } from './shared/usage-display'; +const LABEL = 'Block: '; +const BAR_LABEL = 'Block '; + const NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when there is no active block' }; export class BlockTimerWidget implements Widget { @@ -41,6 +44,10 @@ export class BlockTimerWidget implements Widget { getDescription(): string { return 'Shows current 5hr block elapsed time or progress'; } getDisplayName(): string { return 'Block Timer'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -77,7 +84,7 @@ export class BlockTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -85,10 +92,10 @@ export class BlockTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(previewPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } - return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), compact ? '3h45m' : '3hr 45m'); } const usageData = context.usageData ?? {}; @@ -103,7 +110,7 @@ export class BlockTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const emptyBar = '░'.repeat(barWidth); - return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] ${emptyPercent}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${emptyBar}] ${emptyPercent}`); } if (isUsageSliderMode(displayMode)) { @@ -111,17 +118,17 @@ export class BlockTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${emptySlider} ${emptyPercent}` : emptySlider; - return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } - return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), compact ? '0h' : '0hr 0m'); } if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${formatPercent(percent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -130,11 +137,11 @@ export class BlockTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(percent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } const elapsedTime = formatUsageDuration(window.elapsedMs, compact); - return formatRawOrLabeledValue(item, 'Block: ', elapsedTime); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), elapsedTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index c7b7fc526..7c0dbc760 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -26,11 +26,14 @@ import { import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Cache Hit: '; + export class CacheHitRateWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows prompt cache hit rate (cache reads vs cache writes)'; } getDisplayName(): string { return 'Cache Hit Rate'; } getCategory(): string { return 'Cache'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } @@ -46,25 +49,25 @@ export class CacheHitRateWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(87, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(87, format)); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } const hitRate = getCacheHitRate(tokens); if (hitRate === null) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(0, format)); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(0, format)); } if (hitRate === 0 && hideWhenEmpty) { return null; } - return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(hitRate, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index 0ac0a6c49..cdcc2af61 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -24,11 +24,14 @@ import { import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Cache Read: '; + export class CacheReadWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows cache read tokens served from cache, with context share'; } getDisplayName(): string { return 'Cache Read'; } getCategory(): string { return 'Cache'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } @@ -46,13 +49,13 @@ export class CacheReadWidget implements Widget { const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const value = formatTokensWithPercentage(12000, 64, tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, 'Cache Read: ', value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Read: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (tokens.read === 0 && hideWhenEmpty) { @@ -60,7 +63,7 @@ export class CacheReadWidget implements Widget { } const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, 'Cache Read: ', value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheTimer.ts b/src/widgets/CacheTimer.ts index 4cbb1c1ad..de6ad7ca8 100644 --- a/src/widgets/CacheTimer.ts +++ b/src/widgets/CacheTimer.ts @@ -23,6 +23,8 @@ import { type SymbolSlot } from './shared/symbol-override'; +const LABEL = 'Cache: '; + // Anthropic's ephemeral prompt cache defaults to a 5-minute TTL, but Claude Code // also writes 1-hour breakpoints (cache_control ttl: "1h") for the stable prefix. // The expiry itself is never exposed (the transcript only records token counts), @@ -237,6 +239,7 @@ export class CacheTimerWidget implements Widget { getDescription(): string { return 'Shows time remaining on the prompt cache TTL (5m by default, 1h configurable)'; } getDisplayName(): string { return 'Cache Timer'; } getCategory(): string { return 'Session'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const modifiers: string[] = []; @@ -267,30 +270,30 @@ export class CacheTimerWidget implements Widget { const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, FRESH_SLOT), '4:52')); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), withGlyph(getSlotSymbol(item, FRESH_SLOT), '4:52')); } const transcriptPath = context.data?.transcript_path; if (!transcriptPath) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } const state = getTranscriptState(transcriptPath); if (state.isWorking) { - return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, HOT_SLOT), 'HOT')); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), withGlyph(getSlotSymbol(item, HOT_SLOT), 'HOT')); } const { lastAssistant } = state; if (!lastAssistant) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } const ttlSeconds = getTtlSeconds(item); const remaining = getRemainingSeconds(lastAssistant, ttlSeconds); const glyph = getStateSymbol(item, remaining, ttlSeconds); - return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(glyph, formatCountdown(remaining))); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), withGlyph(glyph, formatCountdown(remaining))); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index f85b9b9c3..1d4cd16cf 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -24,11 +24,14 @@ import { import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Cache Write: '; + export class CacheWriteWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows cache write tokens written to cache, with context share'; } getDisplayName(): string { return 'Cache Write'; } getCategory(): string { return 'Cache'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } @@ -46,13 +49,13 @@ export class CacheWriteWidget implements Widget { const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const value = formatTokensWithPercentage(3000, 16, tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, 'Cache Write: ', value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Write: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (tokens.creation === 0 && hideWhenEmpty) { @@ -60,7 +63,7 @@ export class CacheWriteWidget implements Widget { } const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, 'Cache Write: ', value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/ClaudeAccountEmail.ts b/src/widgets/ClaudeAccountEmail.ts index 7e267e787..00afad82e 100644 --- a/src/widgets/ClaudeAccountEmail.ts +++ b/src/widgets/ClaudeAccountEmail.ts @@ -9,6 +9,10 @@ import type { } from '../types/Widget'; import { getClaudeJsonPath } from '../utils/claude-settings'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Account: '; + interface ClaudeJson { oauthAccount?: { emailAddress?: string } } export class ClaudeAccountEmailWidget implements Widget { @@ -16,13 +20,14 @@ export class ClaudeAccountEmailWidget implements Widget { getDescription(): string { return 'Displays the email of the currently logged-in Claude account'; } getDisplayName(): string { return 'Claude Account Email'; } getCategory(): string { return 'Session'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'you@example.com' : 'Account: you@example.com'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'you@example.com'); } try { @@ -34,7 +39,7 @@ export class ClaudeAccountEmailWidget implements Widget { return null; } - return item.rawValue ? email : `Account: ${email}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), email); } catch { return null; } diff --git a/src/widgets/ClaudeSessionId.ts b/src/widgets/ClaudeSessionId.ts index 27124be5e..3c9291ccb 100644 --- a/src/widgets/ClaudeSessionId.ts +++ b/src/widgets/ClaudeSessionId.ts @@ -6,24 +6,29 @@ import type { WidgetItem } from '../types/Widget'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Session ID: '; + export class ClaudeSessionIdWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the current Claude Code session ID reported in status JSON'; } getDisplayName(): string { return 'Claude Session ID'; } getCategory(): string { return 'Core'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'preview-session-id' : 'Session ID: preview-session-id'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'preview-session-id'); } else { const sessionId = context.data?.session_id; if (!sessionId) { return null; } - return item.rawValue ? sessionId : `Session ID: ${sessionId}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sessionId); } } diff --git a/src/widgets/ClaudeStatus.ts b/src/widgets/ClaudeStatus.ts index ee7bf0261..d59404b28 100644 --- a/src/widgets/ClaudeStatus.ts +++ b/src/widgets/ClaudeStatus.ts @@ -52,6 +52,7 @@ export class ClaudeStatusWidget implements Widget { getDescription(): string { return 'Shows Claude service status from status.claude.com with an optional 48h incident history strip'; } getDisplayName(): string { return 'Claude Status'; } getCategory(): string { return 'Core'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -90,11 +91,11 @@ export class ClaudeStatusWidget implements Widget { if (context.isPreview) { if (!showHistory) { - return formatRawOrLabeledValue(item, LABEL, 'ok'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'ok'); } const previewBuckets: ClaudeIncidentImpact[] = ['none', 'none', 'minor', 'none', 'major', 'none', 'critical', 'none']; const previewBar = previewBuckets.map(bucket => colorize(HISTORY_BAR_CHAR, bucket)).join(''); - const previewStatus = colorize(formatRawOrLabeledValue(item, LABEL, 'ok'), 'none'); + const previewStatus = colorize(formatRawOrLabeledValue(item, this.getLabelPrefix(), 'ok'), 'none'); return `${previewStatus} ${previewBar}`; } @@ -102,21 +103,21 @@ export class ClaudeStatusWidget implements Widget { if (!data || data.error || data.indicator === undefined) { // Degrade quietly on fetch/parse failures instead of breaking the line. if (showHistory) { - return colorize(formatRawOrLabeledValue(item, LABEL, '?'), 'unknown'); + return colorize(formatRawOrLabeledValue(item, this.getLabelPrefix(), '?'), 'unknown'); } - return formatRawOrLabeledValue(item, LABEL, '?'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '?'); } const statusText = INDICATOR_TEXT[data.indicator] ?? data.indicator; if (!showHistory) { - return formatRawOrLabeledValue(item, LABEL, statusText); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), statusText); } // Color the label together with the status. The renderer deliberately // skips its theme foreground while preserving the multi-colored strip, // so leaving the label uncolored could make it unreadable in Powerline. const coloredStatus = colorize( - formatRawOrLabeledValue(item, LABEL, statusText), + formatRawOrLabeledValue(item, this.getLabelPrefix(), statusText), getIndicatorColorKey(data.indicator) ); const buckets = computeIncidentHistoryBuckets(data.incidents ?? [], Date.now()); diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 16e42fce1..5079e752f 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -18,8 +18,11 @@ import { import { formatTokens } from '../utils/renderer'; import { makeUsageProgressBar } from '../utils/usage'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { makeSliderBar } from './shared/usage-display'; +const LABEL = 'Context: '; + type DisplayMode = 'progress' | 'progress-short' | 'slider' | 'slider-only'; function getDisplayMode(item: WidgetItem): DisplayMode { @@ -39,6 +42,7 @@ export class ContextBarWidget implements Widget { getDescription(): string { return 'Shows context usage as a progress bar'; } getDisplayName(): string { return 'Context Bar'; } getCategory(): string { return 'Context'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const mode = getDisplayMode(item); @@ -93,11 +97,11 @@ export class ContextBarWidget implements Widget { if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(25); const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; - return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), previewDisplay); } const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -127,13 +131,13 @@ export class ContextBarWidget implements Widget { if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(clampedPercent); const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; - return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? display : `Context: ${display}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), display); } getCustomKeybinds(): CustomKeybind[] { @@ -143,6 +147,7 @@ export class ContextBarWidget implements Widget { } supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 969cb5c65..fde94bd3b 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -9,11 +9,16 @@ import { getContextWindowContextLengthTokens } from '../utils/context-window'; import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Ctx: '; + export class ContextLengthWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } getDescription(): string { return 'Shows the current context window size in tokens'; } getDisplayName(): string { return 'Context Length'; } getCategory(): string { return 'Context'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -22,16 +27,16 @@ export class ContextLengthWidget implements Widget { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { const value = formatTokens(18600, format); - return item.rawValue ? value : `Ctx: ${value}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const contextLengthTokens = getContextWindowContextLengthTokens(context.data); if (contextLengthTokens !== null) { - return item.rawValue ? formatTokens(contextLengthTokens, format) : `Ctx: ${formatTokens(contextLengthTokens, format)}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(contextLengthTokens, format)); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.contextLength, format) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength, format)}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(context.tokenMetrics.contextLength, format)); } return null; } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 371a01366..e21b83702 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -31,6 +31,7 @@ export class ContextPercentageWidget implements Widget { getDescription(): string { return 'Shows percentage of context window used or remaining'; } getDisplayName(): string { return 'Context %'; } getCategory(): string { return 'Context'; } + getLabelPrefix(item: WidgetItem): string { return isContextInverse(item) ? 'Ctx Left: ' : 'Ctx Used: '; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const modifiers = [ getContextInverseModifierText(item), @@ -51,14 +52,13 @@ export class ContextPercentageWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const isInverse = isContextInverse(item); - const label = isInverse ? 'Ctx Left: ' : 'Ctx Used: '; const sliderMode = getContextSliderMode(item); const contextPercentageMetrics = calculateContextPercentageMetrics(context); const format = resolveNumberFormat('percent', item, settings); const formatContextPercentage = (displayPercentage: number): string => { const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); - return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 9e8c65b33..4a2e6da4e 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -35,6 +35,7 @@ export class ContextPercentageUsableWidget implements Widget { 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'; } + getLabelPrefix(item: WidgetItem): string { return isContextInverse(item) ? 'Ctx(u) Left: ' : 'Ctx(u) Used: '; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const modifiers = [ getContextInverseModifierText(item), @@ -55,7 +56,6 @@ export class ContextPercentageUsableWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const isInverse = isContextInverse(item); - const label = isInverse ? 'Ctx(u) Left: ' : 'Ctx(u) Used: '; const sliderMode = getContextSliderMode(item); const modelIdentifier = getModelContextIdentifier(context.data?.model); const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -64,7 +64,7 @@ export class ContextPercentageUsableWidget implements Widget { const formatContextPercentage = (displayPercentage: number): string => { const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); - return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index f40ff2dcc..891e41f59 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -13,11 +13,16 @@ import { import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Win: '; + export class ContextWindowWidget implements Widget { getDefaultColor(): string { return 'brightBlack'; } getDescription(): string { return 'Shows the total context window size for the current model'; } getDisplayName(): string { return 'Context Window'; } getCategory(): string { return 'Context'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -26,7 +31,7 @@ export class ContextWindowWidget implements Widget { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { const value = formatTokens(200000, format); - return item.rawValue ? value : `Win: ${value}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } let total = getContextWindowSize(context.data); @@ -40,7 +45,7 @@ export class ContextWindowWidget implements Widget { return null; } - return item.rawValue ? formatTokens(total, format) : `Win: ${formatTokens(total, format)}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(total, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index 70d5686c9..859e067b1 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -17,6 +17,7 @@ import type { } from '../types/Widget'; import { shouldInsertInput } from '../utils/input-guards'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { SYMBOL_OVERRIDE_ACTION, formatSymbolPrefix, @@ -24,11 +25,14 @@ import { renderSymbolOverrideEditor } from './shared/symbol-override'; +const LABEL = 'cwd: '; + export class CurrentWorkingDirWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows the current working directory'; } getDisplayName(): string { return 'Current Working Dir'; } getCategory(): string { return 'Environment'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const segments = item.metadata?.segments ? Number.parseInt(item.metadata.segments, 10) : undefined; const fishStyle = item.metadata?.fishStyle === 'true'; @@ -135,7 +139,7 @@ export class CurrentWorkingDirWidget implements Widget { previewPath = '/Users/example/Documents/Projects/my-project'; } - return item.rawValue ? `${symbolPrefix}${previewPath}` : `${symbolPrefix}cwd: ${previewPath}`; + return `${symbolPrefix}${formatRawOrLabeledValue(item, this.getLabelPrefix(), previewPath)}`; } const cwd = context.data?.cwd; @@ -172,7 +176,7 @@ export class CurrentWorkingDirWidget implements Widget { } } - return item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`; + return `${symbolPrefix}${formatRawOrLabeledValue(item, this.getLabelPrefix(), displayPath)}`; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/CustomSymbol.tsx b/src/widgets/CustomSymbol.tsx index ff388bf51..7bba066ce 100644 --- a/src/widgets/CustomSymbol.tsx +++ b/src/widgets/CustomSymbol.tsx @@ -18,6 +18,7 @@ import type { import { shouldInsertInput } from '../utils/input-guards'; import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; +import { getGraphemes } from './shared/text-cursor'; export class CustomSymbolWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -59,22 +60,6 @@ export class CustomSymbolWidget implements Widget { const CustomSymbolEditor: React.FC = ({ widget, onComplete, onCancel }) => { const [symbol, setSymbol] = useState(widget.customSymbol ?? ''); - // Helper to get grapheme segments if Intl.Segmenter is available - const getFirstGrapheme = (str: string): string => { - if (str.length === 0) { - return ''; - } - - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - const segments = Array.from(segmenter.segment(str)); - return segments[0]?.segment ?? ''; - } - - // Fallback: just take first character - return Array.from(str)[0] ?? ''; - }; - useInput((input, key) => { if (key.return) { onComplete({ ...widget, customSymbol: symbol }); @@ -84,7 +69,7 @@ const CustomSymbolEditor: React.FC = ({ widget, onComplete, o setSymbol(''); } else if (shouldInsertInput(input, key)) { // Take only the first grapheme (handles multi-byte emojis correctly) - const firstGrapheme = getFirstGrapheme(input); + const firstGrapheme = getGraphemes(input)[0] ?? ''; setSymbol(firstGrapheme); } }); diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 03ba01724..9af974825 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -3,7 +3,7 @@ import { Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; @@ -15,9 +15,9 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { shouldInsertInput } from '../utils/input-guards'; import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; +import { useTextCursor } from './shared/text-cursor'; export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -57,124 +57,21 @@ export class CustomTextWidget implements Widget { } const CustomTextEditor: React.FC = ({ widget, onComplete, onCancel }) => { - const [text, setText] = useState(widget.customText ?? ''); - const [cursorPos, setCursorPos] = useState(text.length); - - // Helper to get grapheme segments if Intl.Segmenter is available - const getGraphemes = (str: string): string[] => { - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - return Array.from(segmenter.segment(str), seg => seg.segment); - } - // Fallback to simple character array (won't handle complex emojis perfectly) - return Array.from(str); - }; - - // Convert between grapheme index and string index - const graphemeToStringIndex = (str: string, graphemeIndex: number): number => { - const graphemes = getGraphemes(str); - let stringIndex = 0; - for (let i = 0; i < Math.min(graphemeIndex, graphemes.length); i++) { - const grapheme = graphemes[i]; - if (grapheme) { - stringIndex += grapheme.length; - } - } - return stringIndex; - }; - - const stringToGraphemeIndex = (str: string, stringIndex: number): number => { - const graphemes = getGraphemes(str); - let currentStringIndex = 0; - for (let i = 0; i < graphemes.length; i++) { - if (currentStringIndex >= stringIndex) - return i; - const grapheme = graphemes[i]; - if (grapheme) { - currentStringIndex += grapheme.length; - } - } - return graphemes.length; - }; + const { getText, display, handleInput } = useTextCursor(widget.customText ?? ''); useInput((input, key) => { if (key.return) { - onComplete({ ...widget, customText: text }); + onComplete({ ...widget, customText: getText() }); } else if (key.escape) { onCancel(); - } else if (key.leftArrow) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - if (currentGraphemeIndex > 0) { - const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); - setCursorPos(newStringIndex); - } - } else if (key.rightArrow) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - const graphemeCount = getGraphemes(text).length; - if (currentGraphemeIndex < graphemeCount) { - const newStringIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); - setCursorPos(newStringIndex); - } - } else if (key.ctrl && input === 'ArrowLeft') { - setCursorPos(0); - } else if (key.ctrl && input === 'ArrowRight') { - setCursorPos(text.length); - } else if (key.backspace) { - if (cursorPos > 0) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - if (currentGraphemeIndex > 0) { - const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex - 1); - const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex); - setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); - setCursorPos(deleteFromIndex); - } - } - } else if (key.delete) { - if (cursorPos < text.length) { - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - const graphemeCount = getGraphemes(text).length; - if (currentGraphemeIndex < graphemeCount) { - const deleteFromIndex = graphemeToStringIndex(text, currentGraphemeIndex); - const deleteToIndex = graphemeToStringIndex(text, currentGraphemeIndex + 1); - setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); - } - } - } else if (shouldInsertInput(input, key)) { - // Insert the input at cursor position - const newText = text.slice(0, cursorPos) + input + text.slice(cursorPos); - setText(newText); - - // Move cursor by the actual string length of the input - // This handles multi-byte characters including emojis with modifiers - setCursorPos(cursorPos + input.length); + } else { + handleInput(input, key); } }); - // Get the grapheme at cursor position for display - const graphemes = getGraphemes(text); - const currentGraphemeIndex = stringToGraphemeIndex(text, cursorPos); - - // Build display with ANSI codes for cursor highlighting - let display = 'Enter custom text: '; - for (let i = 0; i < graphemes.length; i++) { - const grapheme = graphemes[i]; - if (grapheme) { - if (i === currentGraphemeIndex) { - // Use inverse video for cursor position - display += `\x1b[7m${grapheme}\x1b[0m`; - } else { - display += grapheme; - } - } - } - if (currentGraphemeIndex >= graphemes.length) { - // Cursor at end - display += '\x1b[7m \x1b[0m'; - } - return ( - {display} + {`Enter custom text: ${display}`} ←→ move cursor, Ctrl+←→ jump to start/end, Enter save, ESC cancel ); diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 838ab59cd..2909de361 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -15,11 +15,14 @@ import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; +const LABEL = 'Overage Left: '; + export class ExtraUsageRemainingWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows the remaining amount of your monthly extra usage limit'; } getDisplayName(): string { return 'Extra Usage Remaining'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; @@ -32,14 +35,14 @@ export class ExtraUsageRemainingWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Overage Left: ', formatUsageCurrency(3894, undefined, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatUsageCurrency(3894, undefined, format)); } const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null - : formatRawOrLabeledValue(item, 'Overage Left: ', 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageLimit === undefined || data.extraUsageUsed === undefined) { if (data.error) { @@ -56,7 +59,7 @@ export class ExtraUsageRemainingWidget implements Widget { const remaining = Math.max(0, limitDollars - usedDollars); const formatted = formatUsageCurrency(remaining, data.extraUsageCurrency, format); - return formatRawOrLabeledValue(item, 'Overage Left: ', formatted); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 8faaba7fd..4f6e1374c 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -15,11 +15,14 @@ import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; +const LABEL = 'Overage Used: '; + export class ExtraUsageUsedWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows amount spent on extra usage (pay-as-you-go overage)'; } getDisplayName(): string { return 'Extra Usage Used'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; @@ -32,14 +35,14 @@ export class ExtraUsageUsedWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Overage Used: ', formatUsageCurrency(106, undefined, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatUsageCurrency(106, undefined, format)); } const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null - : formatRawOrLabeledValue(item, 'Overage Used: ', 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUsed === undefined) { if (data.error) { @@ -54,7 +57,7 @@ export class ExtraUsageUsedWidget implements Widget { const usedDollars = data.extraUsageUsed / 100; const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format); - return formatRawOrLabeledValue(item, 'Overage Used: ', formatted); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 851b7c65f..563b4dfef 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -31,11 +31,14 @@ import { toggleUsageInverted } from './shared/usage-display'; +const LABEL = 'Overage: '; + export class ExtraUsageUtilizationWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows extra usage (pay-as-you-go) utilization percentage'; } getDisplayName(): string { return 'Extra Usage Utilization'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -72,23 +75,23 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null - : formatRawOrLabeledValue(item, 'Overage: ', 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUtilization === undefined) { if (data.error) { @@ -106,16 +109,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index 86dc78345..6bc331635 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -16,6 +16,7 @@ import { getUsagePercentWidgetDescription, getUsagePercentWidgetDisplayName, getUsagePercentWidgetEditorDisplay, + getUsagePercentWidgetLabel, handleUsagePercentWidgetEditorAction, renderUsagePercentWidgetValue } from './shared/usage-percent-widget'; @@ -25,6 +26,7 @@ export class FableWeeklyUsageWidget implements Widget { getDescription(): string { return getUsagePercentWidgetDescription('fable-weekly'); } getDisplayName(): string { return getUsagePercentWidgetDisplayName('fable-weekly'); } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return getUsagePercentWidgetLabel('fable-weekly'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getUsagePercentWidgetEditorDisplay('fable-weekly', item); diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index 836543a5e..bc298c864 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -14,6 +14,10 @@ import { resolveNumberFormat } from '../utils/number-format'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Mem: '; + function formatBytes(bytes: number, format: NumberFormat): string { const GB = 1024 ** 3; const MB = 1024 ** 2; @@ -71,6 +75,7 @@ export class FreeMemoryWidget implements Widget { getDescription(): string { return 'Shows system memory usage (used/total)'; } getDisplayName(): string { return 'Memory Usage'; } getCategory(): string { return 'Environment'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -79,7 +84,7 @@ export class FreeMemoryWidget implements Widget { const format = resolveNumberFormat('memory', item, settings); if (context.isPreview) { const value = `${formatBytes(12.4 * 1024 ** 3, format)}/${formatBytes(16 * 1024 ** 3, format)}`; - return item.rawValue ? value : `Mem: ${value}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const total = os.totalmem(); @@ -95,7 +100,7 @@ export class FreeMemoryWidget implements Widget { const value = `${formatBytes(used, format)}/${formatBytes(total, format)}`; - return item.rawValue ? value : `Mem: ${value}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index af2550203..8b0a64904 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -15,6 +15,7 @@ import { getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, getSpeedWidgetHideableStates, + getSpeedWidgetLabel, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -24,6 +25,7 @@ export class InputSpeedWidget implements Widget { getDescription(): string { return getSpeedWidgetDescription('input'); } getDisplayName(): string { return getSpeedWidgetDisplayName('input'); } getCategory(): string { return 'Token Speed'; } + getLabelPrefix(): string { return getSpeedWidgetLabel('input'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getSpeedWidgetEditorDisplay('input', item); } diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index 7d6db0584..ea6b650b6 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -6,18 +6,23 @@ import type { WidgetItem } from '../types/Widget'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Model: '; + export class ModelWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Displays the Claude model name (e.g., Claude 3.5 Sonnet)'; } getDisplayName(): string { return 'Model'; } getCategory(): string { return 'Core'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'Claude' : 'Model: Claude'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'Claude'); } const model = context.data?.model; @@ -27,7 +32,7 @@ export class ModelWidget implements Widget { if (modelDisplayName) { const shortName = modelDisplayName.replace(/\s*\(.*\)$/, ''); - return item.rawValue ? shortName : `Model: ${shortName}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), shortName); } return null; } diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 228eba714..1205ae688 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -15,6 +15,7 @@ import { getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, getSpeedWidgetHideableStates, + getSpeedWidgetLabel, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -24,6 +25,7 @@ export class OutputSpeedWidget implements Widget { getDescription(): string { return getSpeedWidgetDescription('output'); } getDisplayName(): string { return getSpeedWidgetDisplayName('output'); } getCategory(): string { return 'Token Speed'; } + getLabelPrefix(): string { return getSpeedWidgetLabel('output'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getSpeedWidgetEditorDisplay('output', item); } diff --git a/src/widgets/OutputStyle.ts b/src/widgets/OutputStyle.ts index 5d907c078..7c586a008 100644 --- a/src/widgets/OutputStyle.ts +++ b/src/widgets/OutputStyle.ts @@ -8,6 +8,9 @@ import type { } from '../types/Widget'; import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Style: '; const DEFAULT_VALUE_HIDEABLE_STATE: HideableState = { key: 'default-value', label: 'when style is \'default\'' }; @@ -16,6 +19,7 @@ export class OutputStyleWidget implements Widget { getDescription(): string { return 'Shows the current Claude Code output style'; } getDisplayName(): string { return 'Output Style'; } getCategory(): string { return 'Core'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -26,13 +30,13 @@ export class OutputStyleWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'default' : 'Style: default'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'default'); } else if (context.data?.output_style?.name) { const styleName = context.data.output_style.name; if (styleName === 'default' && isHidden(item, DEFAULT_VALUE_HIDEABLE_STATE.key)) { return null; } - return item.rawValue ? styleName : `Style: ${styleName}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), styleName); } return null; } diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index 5b104011c..4b3ad3523 100644 --- a/src/widgets/SessionClock.ts +++ b/src/widgets/SessionClock.ts @@ -8,6 +8,9 @@ import type { } from '../types/Widget'; import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Session: '; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when under 1 minute' }; @@ -36,6 +39,7 @@ export class SessionClockWidget implements Widget { getDescription(): string { return 'Shows elapsed time since current session started'; } getDisplayName(): string { return 'Session Clock'; } getCategory(): string { return 'Session'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -46,7 +50,7 @@ export class SessionClockWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? '2hr 15m' : 'Session: 2hr 15m'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '2hr 15m'); } const hideZero = isHidden(item, ZERO_HIDEABLE_STATE.key); @@ -57,14 +61,14 @@ export class SessionClockWidget implements Widget { return null; } const formatted = formatDurationFromMs(durationMs); - return item.rawValue ? formatted : `Session: ${formatted}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } const duration = context.sessionDuration ?? '0m'; if ((duration === '0m' || duration === '<1m') && hideZero) { return null; } - return item.rawValue ? duration : `Session: ${duration}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), duration); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 07e336f00..bf97b2b9a 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -12,6 +12,9 @@ import { } from '../utils/number-format'; import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Cost: '; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when cost is $0.00' }; @@ -20,6 +23,7 @@ export class SessionCostWidget implements Widget { getDescription(): string { return 'Shows the total session cost in USD'; } getDisplayName(): string { return 'Session Cost'; } getCategory(): string { return 'Session'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -32,7 +36,7 @@ export class SessionCostWidget implements Widget { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { const value = formatCost(2.45, format); - return item.rawValue ? value : `Cost: ${value}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const totalCost = context.data?.cost?.total_cost_usd; @@ -48,7 +52,7 @@ export class SessionCostWidget implements Widget { } const formattedCost = formatCost(totalCost, format); - return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formattedCost); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index f3bfb7e6d..5604b4119 100644 --- a/src/widgets/SessionName.ts +++ b/src/widgets/SessionName.ts @@ -7,18 +7,23 @@ import type { } from '../types/Widget'; import { getTranscriptSessionName } from '../utils/jsonl-session'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Session: '; + export class SessionNameWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the session name set via /rename command in Claude Code'; } getDisplayName(): string { return 'Session Name'; } getCategory(): string { return 'Session'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'my-session' : 'Session: my-session'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'my-session'); } const sessionName = context.transcriptSessionName === undefined @@ -28,7 +33,7 @@ export class SessionNameWidget implements Widget { return null; } - return item.rawValue ? sessionName : `Session: ${sessionName}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sessionName); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 774231348..e3b80f596 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -16,6 +16,7 @@ import { getUsagePercentWidgetDescription, getUsagePercentWidgetDisplayName, getUsagePercentWidgetEditorDisplay, + getUsagePercentWidgetLabel, handleUsagePercentWidgetEditorAction, renderUsagePercentWidgetValue } from './shared/usage-percent-widget'; @@ -25,6 +26,7 @@ export class SessionUsageWidget implements Widget { getDescription(): string { return getUsagePercentWidgetDescription('session'); } getDisplayName(): string { return getUsagePercentWidgetDisplayName('session'); } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return getUsagePercentWidgetLabel('session'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getUsagePercentWidgetEditorDisplay('session', item); diff --git a/src/widgets/TerminalWidth.ts b/src/widgets/TerminalWidth.ts index 7b88b83e9..b088895c1 100644 --- a/src/widgets/TerminalWidth.ts +++ b/src/widgets/TerminalWidth.ts @@ -7,11 +7,16 @@ import type { } from '../types/Widget'; import { getTerminalWidth } from '../utils/terminal'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Term: '; + export class TerminalWidthWidget implements Widget { getDefaultColor(): string { return 'gray'; } getDescription(): string { return 'Shows current terminal width in columns'; } getDisplayName(): string { return 'Terminal Width'; } getCategory(): string { return 'Environment'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -20,9 +25,9 @@ export class TerminalWidthWidget implements Widget { const width = context.terminalWidth ?? getTerminalWidth(); if (context.isPreview) { const detectedWidth = width ?? '??'; - return item.rawValue ? `${detectedWidth}` : `Term: ${detectedWidth}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${detectedWidth}`); } else if (width) { - return item.rawValue ? `${width}` : `Term: ${width}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${width}`); } return null; } diff --git a/src/widgets/ThinkingEffort.ts b/src/widgets/ThinkingEffort.ts index 0d5ea4eaa..eb28fb0c6 100644 --- a/src/widgets/ThinkingEffort.ts +++ b/src/widgets/ThinkingEffort.ts @@ -13,6 +13,10 @@ import { type TranscriptThinkingEffort } from '../utils/jsonl'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'Thinking: '; + export type ThinkingEffortLevel = TranscriptThinkingEffort; function resolveThinkingEffortFromStatusJson(context: RenderContext): ResolvedThinkingEffort | null | undefined { @@ -62,17 +66,18 @@ export class ThinkingEffortWidget implements Widget { getDescription(): string { return 'Displays the current thinking effort level (low, medium, high, xhigh, max).\nClaude Code reports Ultracode as xhigh in status line data; Ultracode is not exposed as a separate effort level.\nUnknown levels are shown with a trailing "?" (e.g. "super-max?").\nMay be incorrect when multiple Claude Code sessions are running due to current Claude Code limitations.'; } getDisplayName(): string { return 'Thinking Effort'; } getCategory(): string { return 'Core'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'high' : 'Thinking: high'; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'high'); } const effort = formatEffort(resolveThinkingEffort(context)); - return item.rawValue ? effort : `Thinking: ${effort}`; + return formatRawOrLabeledValue(item, this.getLabelPrefix(), effort); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index e0bd1d325..eab9e74ce 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -15,6 +15,7 @@ import { getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, getSpeedWidgetHideableStates, + getSpeedWidgetLabel, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -24,6 +25,7 @@ export class TotalSpeedWidget implements Widget { getDescription(): string { return getSpeedWidgetDescription('total'); } getDisplayName(): string { return getSpeedWidgetDisplayName('total'); } getCategory(): string { return 'Token Speed'; } + getLabelPrefix(): string { return getSpeedWidgetLabel('total'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getSpeedWidgetEditorDisplay('total', item); } diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 150d0d463..5c5fca343 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -16,6 +16,7 @@ import { getUsagePercentWidgetDescription, getUsagePercentWidgetDisplayName, getUsagePercentWidgetEditorDisplay, + getUsagePercentWidgetLabel, handleUsagePercentWidgetEditorAction, renderUsagePercentWidgetValue } from './shared/usage-percent-widget'; @@ -25,6 +26,7 @@ export class WeeklyOpusUsageWidget implements Widget { getDescription(): string { return getUsagePercentWidgetDescription('weekly-opus'); } getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly-opus'); } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return getUsagePercentWidgetLabel('weekly-opus'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getUsagePercentWidgetEditorDisplay('weekly-opus', item); diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index bdeb4e8d8..8803c20a1 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -62,6 +62,9 @@ import { toggleUsageWeekday } from './shared/usage-display'; +const LABEL = 'Weekly Reset: '; +const BAR_LABEL = 'Weekly Reset '; + const WEEKLY_PREVIEW_DURATION_MS = 36.5 * 60 * 60 * 1000; const WEEKLY_RESET_PREVIEW_AT = '2026-03-15T08:30:00.000Z'; const USAGE_TIMER_LOADING_MESSAGE = '[Loading]'; @@ -132,6 +135,10 @@ export class WeeklyResetTimerWidget implements Widget { getDescription(): string { return 'Shows time remaining until weekly usage reset'; } getDisplayName(): string { return 'Weekly Reset Timer'; } getCategory(): string { return 'Usage'; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -190,7 +197,7 @@ export class WeeklyResetTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -198,7 +205,7 @@ export class WeeklyResetTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(previewPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } if (dateMode) { @@ -214,10 +221,10 @@ export class WeeklyResetTimerWidget implements Widget { const fallback = weekday ? (compact ? 'Sun 08:30Z' : 'Sun 08:30 UTC') : (compact ? '03-15 08:30Z' : '2026-03-15 08:30 UTC'); - return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt ?? fallback); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt ?? fallback); } - return formatRawOrLabeledValue(item, 'Weekly Reset: ', formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); } const usageData = context.usageData ?? {}; @@ -232,14 +239,14 @@ export class WeeklyResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, 'Weekly Reset: ', USAGE_TIMER_LOADING_MESSAGE); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), USAGE_TIMER_LOADING_MESSAGE); } if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${formatPercent(percent, format)}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { @@ -248,7 +255,7 @@ export class WeeklyResetTimerWidget implements Widget { const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(percent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), sliderDisplay); } if (dateMode) { @@ -256,12 +263,12 @@ export class WeeklyResetTimerWidget implements Widget { const locale = getUsageLocale(item); const resetAt = formatUsageResetAt(usageData.weeklyResetAt, compact, timezone, locale, isUsage12HourClock(item), isUsageWeekdayEnabled(item)); if (resetAt) { - return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact, useDays); - return formatRawOrLabeledValue(item, 'Weekly Reset: ', remainingTime); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), remainingTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 96eff8cc7..d16406183 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -16,6 +16,7 @@ import { getUsagePercentWidgetDescription, getUsagePercentWidgetDisplayName, getUsagePercentWidgetEditorDisplay, + getUsagePercentWidgetLabel, handleUsagePercentWidgetEditorAction, renderUsagePercentWidgetValue } from './shared/usage-percent-widget'; @@ -25,6 +26,7 @@ export class WeeklySonnetUsageWidget implements Widget { getDescription(): string { return getUsagePercentWidgetDescription('weekly-sonnet'); } getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly-sonnet'); } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return getUsagePercentWidgetLabel('weekly-sonnet'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getUsagePercentWidgetEditorDisplay('weekly-sonnet', item); diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 86544d557..7e08d5360 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -16,6 +16,7 @@ import { getUsagePercentWidgetDescription, getUsagePercentWidgetDisplayName, getUsagePercentWidgetEditorDisplay, + getUsagePercentWidgetLabel, handleUsagePercentWidgetEditorAction, renderUsagePercentWidgetValue } from './shared/usage-percent-widget'; @@ -25,6 +26,7 @@ export class WeeklyUsageWidget implements Widget { getDescription(): string { return getUsagePercentWidgetDescription('weekly'); } getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly'); } getCategory(): string { return 'Usage'; } + getLabelPrefix(): string { return getUsagePercentWidgetLabel('weekly'); } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return getUsagePercentWidgetEditorDisplay('weekly', item); diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index a930bcf10..897a64bc6 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -117,6 +117,7 @@ describe('BlockResetTimerWidget', () => { expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBe('Reset: [Loading]'); expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); + expect(render(widget, { id: 'reset', type: 'reset-timer', metadata: { display: 'progress' } }, { usageData: {} })).toBe('Reset [Loading]'); }); it('declares the no-data hideable state', () => { diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index c219aef8b..17419afe7 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -53,6 +53,14 @@ describe('ContextPercentageWidget', () => { }).modifierText).toBe('(remaining)'); }); + it('reports the default label for the current used/remaining mode', () => { + const widget = new ContextPercentageWidget(); + const base: WidgetItem = { id: 'context-percentage', type: 'context-percentage' }; + + expect(widget.getLabelPrefix(base)).toBe('Ctx Used: '); + expect(widget.getLabelPrefix({ ...base, metadata: { inverse: 'true' } })).toBe('Ctx Left: '); + }); + it('prefers context_window percentage over token metrics when both exist', () => { const widget = new ContextPercentageWidget(); const item: WidgetItem = { diff --git a/src/widgets/__tests__/GitCountWidgets.test.ts b/src/widgets/__tests__/GitCountWidgets.test.ts index df39b8793..fe0f5aa1b 100644 --- a/src/widgets/__tests__/GitCountWidgets.test.ts +++ b/src/widgets/__tests__/GitCountWidgets.test.ts @@ -176,6 +176,18 @@ describe('Git Staged, Unstaged and Untracked Files', () => { expect('getCustomKeybinds' in widget).toBe(false); expect('renderEditor' in widget).toBe(false); }); + + it.each(FILES)('$type exposes an editable label for live and preview counts', ({ type, widget }) => { + const item: WidgetItem = { id: 'w', type }; + const defaults: Record = { 'git-staged-files': 'S:', 'git-unstaged-files': 'M:', 'git-untracked-files': '?:' }; + expect(widget.getLabelPrefix?.(item)).toBe(defaults[type]); + for (const context of [live('dirty'), preview]) { + const value = render(widget, type, context, { rawValue: true }); + expect(render(widget, type, context, { metadata: { label: 'Files ' } })).toBe(`Files ${value}`); + expect(render(widget, type, context, { metadata: { label: '' } })).toBe(value); + expect(render(widget, type, context, { rawValue: true, metadata: { label: 'Files ' } })).toBe(value); + } + }); }); // The glyph editor's rows, as passed to the shared symbol editor diff --git a/src/widgets/__tests__/Model.test.ts b/src/widgets/__tests__/Model.test.ts index 4ca4d4d44..51b5feedb 100644 --- a/src/widgets/__tests__/Model.test.ts +++ b/src/widgets/__tests__/Model.test.ts @@ -61,6 +61,11 @@ describe('ModelWidget', () => { expect(new ModelWidget().render(RAW_ITEM, ctx, DEFAULT_SETTINGS)).toBe('Claude'); }); + it('uses the label override in place of the default label', () => { + const ctx = makeContext({ data: { model: { id: 'claude-opus-4-6', display_name: 'Opus 4.6' } } }); + expect(new ModelWidget().render({ ...ITEM, metadata: { label: 'M ' } }, ctx, DEFAULT_SETTINGS)).toBe('M Opus 4.6'); + }); + it('falls back to model id when display_name is absent', () => { const ctx = makeContext({ data: { model: { id: 'claude-opus-4-6[1m]' } } }); expect(new ModelWidget().render(RAW_ITEM, ctx, DEFAULT_SETTINGS)).toBe('claude-opus-4-6[1m]'); diff --git a/src/widgets/__tests__/SessionUsage.test.ts b/src/widgets/__tests__/SessionUsage.test.ts index bec6dc832..2d3a59946 100644 --- a/src/widgets/__tests__/SessionUsage.test.ts +++ b/src/widgets/__tests__/SessionUsage.test.ts @@ -63,6 +63,20 @@ describe('SessionUsageWidget', () => { }, context)).toBe('Session: ▓▓░░░│░░░░'); }); + it('applies the label override to every display mode', () => { + const widget = new SessionUsageWidget(); + const context: RenderContext = { usageData: { sessionUsage: 20 } }; + const metadata = { label: '5h ' }; + + expect(widget.getLabelPrefix()).toBe('Session: '); + expect(render(widget, { id: 'session', type: 'session-usage', metadata }, context)).toBe('5h 20.0%'); + expect(render(widget, { + id: 'session', + type: 'session-usage', + metadata: { ...metadata, display: 'slider-only' } + }, context)).toBe('5h ▓▓░░░░░░░░'); + }); + runUsagePercentWidgetSuite({ baseItem: { id: 'session', type: 'session-usage' }, createWidget: () => new SessionUsageWidget(), diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index eb475abc1..ade9cae23 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -352,6 +352,28 @@ describe('Token widgets', () => { expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:30600'); }); + it('exposes editable labels and applies overrides to live and preview counts', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { tokenMetrics: { inputTokens: 1, outputTokens: 2, cachedTokens: 3, totalTokens: 6, contextLength: 0 } }; + const cases = [ + { widget: new TokensInputWidget(), type: 'tokens-input', label: 'In: ', count: 1, preview: 15200 }, + { widget: new TokensOutputWidget(), type: 'tokens-output', label: 'Out: ', count: 2, preview: 3400 }, + { widget: new TokensCachedWidget(), type: 'tokens-cached', label: 'Cached: ', count: 3, preview: 12000 }, + { widget: new TokensTotalWidget(), type: 'tokens-total', label: 'Total: ', count: 6, preview: 30600 } + ]; + + for (const { widget, type, label, count, preview } of cases) { + expect(widget.getLabelPrefix()).toBe(label); + for (const isPreview of [false, true]) { + const value = `fmt:${isPreview ? preview : count}`; + const renderContext = { ...context, isPreview }; + expect(widget.render({ id: type, type, metadata: { label: 'T ' } }, renderContext, DEFAULT_SETTINGS)).toBe(`T ${value}`); + expect(widget.render({ id: type, type, metadata: { label: '' } }, renderContext, DEFAULT_SETTINGS)).toBe(value); + expect(widget.render({ id: type, type, rawValue: true, metadata: { label: 'T ' } }, renderContext, DEFAULT_SETTINGS)).toBe(value); + } + } + }); + it('formats every preview sample with the selected token style', async () => { const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); const context: RenderContext = { isPreview: true }; diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index e35a9d7d3..efa575522 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -148,6 +148,7 @@ describe('WeeklyResetTimerWidget', () => { expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBe('Weekly Reset: [Loading]'); expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { display: 'progress' } }, { usageData: {} })).toBe('Weekly Reset [Loading]'); }); it('declares the no-data hideable state', () => { diff --git a/src/widgets/shared/__tests__/raw-or-labeled.test.ts b/src/widgets/shared/__tests__/raw-or-labeled.test.ts new file mode 100644 index 000000000..d47e99109 --- /dev/null +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -0,0 +1,70 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../../types/Widget'; +import { + clearLabel, + formatRawOrLabeledValue, + getLabel, + getLabelModifierText, + setLabel +} from '../raw-or-labeled'; + +function makeItem(overrides: Partial = {}): WidgetItem { + return { id: '1', type: 'model', ...overrides }; +} + +describe('formatRawOrLabeledValue', () => { + it('prepends the default label', () => { + expect(formatRawOrLabeledValue(makeItem(), 'Model: ', 'Opus')).toBe('Model: Opus'); + }); + + it('prepends the label override verbatim', () => { + expect(formatRawOrLabeledValue(makeItem({ metadata: { label: 'M ' } }), 'Model: ', 'Opus')).toBe('M Opus'); + }); + + it('treats an empty override as no label', () => { + expect(formatRawOrLabeledValue(makeItem({ metadata: { label: '' } }), 'Model: ', 'Opus')).toBe('Opus'); + }); + + it('ignores the override in raw value mode', () => { + expect(formatRawOrLabeledValue(makeItem({ rawValue: true, metadata: { label: 'M ' } }), 'Model: ', 'Opus')).toBe('Opus'); + }); +}); + +describe('setLabel', () => { + it('stores an override that differs from the default', () => { + const item = setLabel(makeItem(), 'M '); + expect(item.metadata).toEqual({ label: 'M ' }); + expect(getLabel(item, 'Model: ')).toBe('M '); + }); + + it('keeps an empty override', () => { + expect(setLabel(makeItem(), '').metadata).toEqual({ label: '' }); + }); + + it('stores an override even when it matches the default', () => { + expect(setLabel(makeItem(), 'Model: ').metadata).toEqual({ label: 'Model: ' }); + }); +}); + +describe('clearLabel', () => { + it('drops the override, preserving other metadata', () => { + expect(clearLabel(makeItem({ metadata: { label: 'M ', hide: 'zero' } })).metadata).toEqual({ hide: 'zero' }); + expect(clearLabel(makeItem({ metadata: { label: 'M ' } })).metadata).toBeUndefined(); + }); +}); + +describe('getLabelModifierText', () => { + it('quotes an override so trailing spaces are visible', () => { + expect(getLabelModifierText(makeItem({ metadata: { label: 'M ' } }))).toBe('(label: "M ")'); + expect(getLabelModifierText(makeItem())).toBeUndefined(); + }); + + it('escapes quotes inside the override', () => { + expect(getLabelModifierText(makeItem({ metadata: { label: 'a" b' } }))).toBe('(label: "a\\" b")'); + }); +}); diff --git a/src/widgets/shared/__tests__/text-cursor.test.ts b/src/widgets/shared/__tests__/text-cursor.test.ts new file mode 100644 index 000000000..9f97031e6 --- /dev/null +++ b/src/widgets/shared/__tests__/text-cursor.test.ts @@ -0,0 +1,80 @@ +import type { Key } from 'ink'; +import { + describe, + expect, + it +} from 'vitest'; + +import { + applyTextCursorInput, + renderTextWithCursor +} from '../text-cursor'; + +function makeKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides + }; +} + +const FAMILY = '👨‍👩‍👧'; + +describe('applyTextCursorInput', () => { + it('inserts at the cursor', () => { + expect(applyTextCursorInput({ text: 'ac', cursor: 1 }, 'b', makeKey())).toEqual({ text: 'abc', cursor: 2 }); + }); + + it('moves and deletes by whole graphemes', () => { + const text = `a${FAMILY}b`; + const afterEmoji = 1 + FAMILY.length; + + expect(applyTextCursorInput({ text, cursor: afterEmoji }, '', makeKey({ leftArrow: true }))).toEqual({ text, cursor: 1 }); + expect(applyTextCursorInput({ text, cursor: 1 }, '', makeKey({ rightArrow: true }))).toEqual({ text, cursor: afterEmoji }); + expect(applyTextCursorInput({ text, cursor: afterEmoji }, '', makeKey({ backspace: true }))).toEqual({ text: 'ab', cursor: 1 }); + expect(applyTextCursorInput({ text, cursor: 1 }, '', makeKey({ delete: true }))).toEqual({ text: 'ab', cursor: 1 }); + }); + + it('stays put at the edges', () => { + const start = { text: 'ab', cursor: 0 }; + const end = { text: 'ab', cursor: 2 }; + + expect(applyTextCursorInput(start, '', makeKey({ leftArrow: true }))).toEqual(start); + expect(applyTextCursorInput(start, '', makeKey({ backspace: true }))).toEqual(start); + expect(applyTextCursorInput(end, '', makeKey({ rightArrow: true }))).toEqual(end); + expect(applyTextCursorInput(end, '', makeKey({ delete: true }))).toEqual(end); + }); + + it('jumps to the start or end with ctrl+arrows', () => { + expect(applyTextCursorInput({ text: 'abc', cursor: 1 }, '', makeKey({ ctrl: true, leftArrow: true }))).toEqual({ text: 'abc', cursor: 0 }); + expect(applyTextCursorInput({ text: 'abc', cursor: 1 }, '', makeKey({ ctrl: true, rightArrow: true }))).toEqual({ text: 'abc', cursor: 3 }); + }); + + it('leaves non-editing keys to the caller', () => { + expect(applyTextCursorInput({ text: 'a', cursor: 1 }, '', makeKey({ return: true }))).toBeNull(); + expect(applyTextCursorInput({ text: 'a', cursor: 1 }, '\t', makeKey({ tab: true }))).toBeNull(); + }); +}); + +describe('renderTextWithCursor', () => { + it('inverts the grapheme under the cursor', () => { + expect(renderTextWithCursor({ text: `a${FAMILY}`, cursor: 1 })).toBe(`a\x1b[7m${FAMILY}\x1b[27m`); + }); + + it('draws a trailing block when the cursor is at the end', () => { + expect(renderTextWithCursor({ text: 'ab', cursor: 2 })).toBe('ab\x1b[7m \x1b[27m'); + expect(renderTextWithCursor({ text: '', cursor: 0 })).toBe('\x1b[7m \x1b[27m'); + }); +}); diff --git a/src/widgets/shared/git-count-widget.ts b/src/widgets/shared/git-count-widget.ts index bd25b89a4..2715f1866 100644 --- a/src/widgets/shared/git-count-widget.ts +++ b/src/widgets/shared/git-count-widget.ts @@ -83,6 +83,8 @@ export abstract class GitFileCountWidget extends GitCountWidgetBase { protected abstract readonly field: keyof GitFileStatusCounts; protected abstract readonly label: string; + getLabelPrefix(): string { return this.label; } + protected getCounts(context: RenderContext): number { return getGitFileStatusCounts(context)[this.field]; } @@ -92,7 +94,7 @@ export abstract class GitFileCountWidget extends GitCountWidgetBase { } protected formatCounts(item: WidgetItem, count: number): string { - return formatRawOrLabeledValue(item, this.label, `${count}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${count}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 94d4d847c..41ef94823 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -1,5 +1,52 @@ -import type { WidgetItem } from '../../types/Widget'; +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { removeMetadataKeys } from './metadata'; + +const LABEL_METADATA_KEY = 'label'; +export const EDIT_LABEL_ACTION = 'edit-label'; + +// 'l' would be the natural mnemonic, but the reset timers already bind it to +// (l)ocale +const LABEL_KEYBIND: CustomKeybind = { + key: 'b', + label: 'la(b)el…', + action: EDIT_LABEL_ACTION +}; + +/** The item's label override, or the widget's default label for its current mode. */ +export function getLabel(item: WidgetItem, defaultLabel: string): string { + return item.metadata?.[LABEL_METADATA_KEY] ?? defaultLabel; +} + +export function clearLabel(item: WidgetItem): WidgetItem { + return removeMetadataKeys(item, [LABEL_METADATA_KEY]); +} + +// Kept even when it equals the current default: widgets whose default changes +// with their mode apply one override in every mode. An empty override means +// "no label" rather than "default". +export function setLabel(item: WidgetItem, value: string): WidgetItem { + return { + ...item, + metadata: { + ...item.metadata, + [LABEL_METADATA_KEY]: value + } + }; +} export function formatRawOrLabeledValue(item: WidgetItem, labelPrefix: string, value: string): string { - return item.rawValue ? value : `${labelPrefix}${value}`; + return item.rawValue ? value : `${getLabel(item, labelPrefix)}${value}`; +} + +export function getLabelKeybind(): CustomKeybind { + return LABEL_KEYBIND; +} + +export function getLabelModifierText(item: WidgetItem): string | undefined { + const label = item.metadata?.[LABEL_METADATA_KEY]; + return label === undefined ? undefined : `(label: ${JSON.stringify(label)})`; } diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index 9373e3e42..d7ed0021a 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -97,6 +97,10 @@ export function getSpeedWidgetDisplayName(kind: SpeedWidgetKind): string { return SPEED_WIDGET_CONFIG[kind].displayName; } +export function getSpeedWidgetLabel(kind: SpeedWidgetKind): string { + return SPEED_WIDGET_CONFIG[kind].label; +} + export function getSpeedWidgetDescription(kind: SpeedWidgetKind): string { return SPEED_WIDGET_CONFIG[kind].description; } diff --git a/src/widgets/shared/symbol-override.tsx b/src/widgets/shared/symbol-override.tsx index 9a7c4b2f9..62cec918c 100644 --- a/src/widgets/shared/symbol-override.tsx +++ b/src/widgets/shared/symbol-override.tsx @@ -14,6 +14,7 @@ import { getVisibleWidth } from '../../utils/ansi'; import { shouldInsertInput } from '../../utils/input-guards'; import { removeMetadataKeys } from './metadata'; +import { getGraphemes } from './text-cursor'; export const SYMBOL_OVERRIDE_ACTION = 'edit-symbol-override'; @@ -88,22 +89,6 @@ export function renderSymbolSlotsEditor(props: WidgetEditorProps, slots: SymbolS return ; } -// Helper to get grapheme segments if Intl.Segmenter is available -function getFirstGrapheme(str: string): string { - if (str.length === 0) { - return ''; - } - - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - const segments = Array.from(segmenter.segment(str)); - return segments[0]?.segment ?? ''; - } - - // Fallback: just take first character - return Array.from(str)[0] ?? ''; -} - const SymbolSlotsEditor: React.FC = ({ widget, slots, onComplete, onCancel }) => { const [values, setValues] = useState(() => slots.map(slot => getSlotSymbol(widget, slot))); const [selectedIndex, setSelectedIndex] = useState(0); @@ -126,7 +111,7 @@ const SymbolSlotsEditor: React.FC = setValues(values.map((value, index) => (index === selectedIndex ? '' : value))); } else if (shouldInsertInput(input, key)) { // Take only the first grapheme (handles multi-byte emojis correctly) - const grapheme = getFirstGrapheme(input); + const grapheme = getGraphemes(input)[0] ?? ''; setValues(values.map((value, index) => (index === selectedIndex ? grapheme : value))); } }); diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts new file mode 100644 index 000000000..acf1e0209 --- /dev/null +++ b/src/widgets/shared/text-cursor.ts @@ -0,0 +1,106 @@ +import type { Key } from 'ink'; +import { + useRef, + useState +} from 'react'; + +import { shouldInsertInput } from '../../utils/input-guards'; + +// cursor is a string index that always sits on a grapheme boundary, so +// multi-codepoint emoji move and delete as a single character +export interface TextCursorState { + text: string; + cursor: number; +} + +const segmenter = 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; + +export function getGraphemes(str: string): string[] { + if (segmenter) { + return Array.from(segmenter.segment(str), seg => seg.segment); + } + // Fallback to simple character array (won't handle complex emojis perfectly) + return Array.from(str); +} + +function getBoundaries(text: string): number[] { + const boundaries = [0]; + for (const grapheme of getGraphemes(text)) { + boundaries.push((boundaries[boundaries.length - 1] ?? 0) + grapheme.length); + } + return boundaries; +} + +/** The state after a keypress, or null when the key is not a text-editing key. */ +export function applyTextCursorInput(state: TextCursorState, input: string, key: Key): TextCursorState | null { + const { text, cursor } = state; + const boundaries = getBoundaries(text); + const index = boundaries.findIndex(boundary => boundary >= cursor); + const previous = boundaries[index - 1]; + const next = boundaries[index + 1]; + + if (key.ctrl && key.leftArrow) { + return { text, cursor: 0 }; + } + if (key.ctrl && key.rightArrow) { + return { text, cursor: text.length }; + } + if (key.leftArrow) { + return previous === undefined ? state : { text, cursor: previous }; + } + if (key.rightArrow) { + return next === undefined ? state : { text, cursor: next }; + } + if (key.backspace) { + return previous === undefined + ? state + : { text: text.slice(0, previous) + text.slice(cursor), cursor: previous }; + } + if (key.delete) { + return next === undefined + ? state + : { text: text.slice(0, cursor) + text.slice(next), cursor }; + } + if (shouldInsertInput(input, key)) { + return { text: text.slice(0, cursor) + input + text.slice(cursor), cursor: cursor + input.length }; + } + return null; +} + +/** The text with the grapheme under the cursor in inverse video (a trailing block at the end). */ +export function renderTextWithCursor({ text, cursor }: TextCursorState): string { + let offset = 0; + const rendered = getGraphemes(text) + .map((grapheme) => { + const underCursor = offset === cursor; + offset += grapheme.length; + return underCursor ? `\x1b[7m${grapheme}\x1b[27m` : grapheme; + }) + .join(''); + + return cursor === text.length ? `${rendered}\x1b[7m \x1b[27m` : rendered; +} + +export function useTextCursor(initialText: string) { + const [state, setState] = useState({ text: initialText, cursor: initialText.length }); + const latestState = useRef(state); + + return { + text: state.text, + display: renderTextWithCursor(state), + getText: (): string => latestState.current.text, + // Returns whether the key was consumed, so callers can fall through + // to their own bindings + handleInput: (input: string, key: Key): boolean => { + const nextState = applyTextCursorInput(latestState.current, input, key); + if (nextState === null) { + return false; + } + // Publish edits immediately so another key or Save sees them + // even before React renders the updated text. + latestState.current = nextState; + setState(nextState); + return true; + } + }; +} diff --git a/src/widgets/shared/token-count-widget.ts b/src/widgets/shared/token-count-widget.ts index 2c8216141..8d9b253c9 100644 --- a/src/widgets/shared/token-count-widget.ts +++ b/src/widgets/shared/token-count-widget.ts @@ -29,6 +29,7 @@ export abstract class TokenCountWidget implements Widget { protected abstract getTokenCount(context: RenderContext): number | null; getCategory(): string { return 'Tokens'; } + getLabelPrefix(): string { return this.label; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -40,7 +41,7 @@ export abstract class TokenCountWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, this.label, formatTokens(this.previewTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(this.previewTokens, format)); } const tokens = this.getTokenCount(context); @@ -52,7 +53,7 @@ export abstract class TokenCountWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, this.label, formatTokens(tokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(tokens, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/shared/usage-percent-widget.ts b/src/widgets/shared/usage-percent-widget.ts index 8e12728ef..6489e867e 100644 --- a/src/widgets/shared/usage-percent-widget.ts +++ b/src/widgets/shared/usage-percent-widget.ts @@ -139,6 +139,10 @@ export function getUsagePercentWidgetDisplayName(kind: UsagePercentWidgetKind): return USAGE_PERCENT_WIDGET_CONFIG[kind].displayName; } +export function getUsagePercentWidgetLabel(kind: UsagePercentWidgetKind): string { + return USAGE_PERCENT_WIDGET_CONFIG[kind].label; +} + export function getUsagePercentWidgetDescription(kind: UsagePercentWidgetKind): string { return USAGE_PERCENT_WIDGET_CONFIG[kind].description; }