From 5e2f644ab50023ba566972b74fc19807cc74b7ac Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Thu, 24 Sep 2026 11:05:14 -0400 Subject: [PATCH 01/15] feat(widgets): add editable label prefix to text-label widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widgets that prepend a text label when raw value is off ("Model: ", "Ctx: ", "In: ", ...) now accept a per-item override stored in metadata.label. The override replaces every label variant of a widget verbatim; an empty override drops the label. Widgets opt in via Widget.getLabelPrefix(item). Rendering goes through formatRawOrLabeledValue, which inline-label widgets now use too. The items editor offers a shared la(b)el… keybind (hidden in raw value mode) that opens a new LabelEditor, and lists overrides as (label: "..."). Co-Authored-By: Claude Opus 5.5 --- src/tui/components/ItemsEditor.tsx | 30 +++++++++ src/tui/components/LabelEditor.tsx | 61 +++++++++++++++++++ .../components/__tests__/ItemsEditor.test.ts | 53 ++++++++++++++++ .../components/items-editor/input-handlers.ts | 7 ++- src/types/Widget.ts | 4 ++ src/utils/__tests__/widgets.test.ts | 28 ++++++++- src/widgets/BlockResetTimer.ts | 13 ++-- src/widgets/BlockTimer.ts | 9 ++- src/widgets/CacheHitRate.ts | 11 ++-- src/widgets/CacheRead.ts | 9 ++- src/widgets/CacheTimer.ts | 13 ++-- src/widgets/CacheWrite.ts | 9 ++- src/widgets/ClaudeAccountEmail.ts | 9 ++- src/widgets/ClaudeSessionId.ts | 9 ++- src/widgets/ClaudeStatus.ts | 1 + src/widgets/ContextBar.ts | 13 ++-- src/widgets/ContextLength.ts | 11 +++- src/widgets/ContextPercentage.ts | 4 +- src/widgets/ContextPercentageUsable.ts | 4 +- src/widgets/ContextWindow.ts | 9 ++- src/widgets/CurrentWorkingDir.tsx | 8 ++- src/widgets/ExtraUsageRemaining.ts | 9 ++- src/widgets/ExtraUsageUsed.ts | 9 ++- src/widgets/ExtraUsageUtilization.ts | 17 +++--- src/widgets/FableWeeklyUsage.ts | 2 + src/widgets/FreeMemory.ts | 9 ++- src/widgets/GitStagedFiles.ts | 8 ++- src/widgets/GitUnstagedFiles.ts | 8 ++- src/widgets/GitUntrackedFiles.ts | 8 ++- src/widgets/InputSpeed.ts | 2 + src/widgets/Model.ts | 9 ++- src/widgets/OutputSpeed.ts | 2 + src/widgets/OutputStyle.ts | 8 ++- src/widgets/SessionClock.ts | 10 ++- src/widgets/SessionCost.ts | 8 ++- src/widgets/SessionName.ts | 9 ++- src/widgets/SessionUsage.ts | 2 + src/widgets/TerminalWidth.ts | 9 ++- src/widgets/ThinkingEffort.ts | 9 ++- src/widgets/TokensCached.ts | 7 ++- src/widgets/TokensInput.ts | 7 ++- src/widgets/TokensOutput.ts | 7 ++- src/widgets/TokensTotal.ts | 7 ++- src/widgets/TotalSpeed.ts | 2 + src/widgets/WeeklyOpusUsage.ts | 2 + src/widgets/WeeklyResetTimer.ts | 13 ++-- src/widgets/WeeklySonnetUsage.ts | 2 + src/widgets/WeeklyUsage.ts | 2 + .../__tests__/ContextPercentage.test.ts | 8 +++ src/widgets/__tests__/Model.test.ts | 5 ++ src/widgets/__tests__/SessionUsage.test.ts | 14 +++++ .../shared/__tests__/raw-or-labeled.test.ts | 60 ++++++++++++++++++ src/widgets/shared/raw-or-labeled.ts | 50 ++++++++++++++- src/widgets/shared/speed-widget.tsx | 4 ++ src/widgets/shared/usage-percent-widget.ts | 4 ++ 55 files changed, 550 insertions(+), 97 deletions(-) create mode 100644 src/tui/components/LabelEditor.tsx create mode 100644 src/widgets/shared/__tests__/raw-or-labeled.test.ts diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 470ee1486..0c7bbb55d 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -31,9 +31,15 @@ import { getHideKeybind, getHideModifierText } from '../../widgets/shared/hideable'; +import { + EDIT_LABEL_ACTION, + 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 +138,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; }; @@ -350,6 +362,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 +605,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 +635,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..8f109f2f9 --- /dev/null +++ b/src/tui/components/LabelEditor.tsx @@ -0,0 +1,61 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { WidgetItem } from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + getLabel, + setLabel +} from '../../widgets/shared/raw-or-labeled'; + +export interface LabelEditorProps { + widget: WidgetItem; + defaultLabel: string; + onComplete: (updatedWidget: WidgetItem) => void; + onCancel: () => void; +} + +function dropLastGrapheme(str: string): string { + const graphemes = 'Segmenter' in Intl + ? Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(str), seg => seg.segment) + : Array.from(str); + return graphemes.slice(0, -1).join(''); +} + +export const LabelEditor: React.FC = ({ widget, defaultLabel, onComplete, onCancel }) => { + const [text, setText] = useState(() => getLabel(widget, defaultLabel)); + + useInput((input, key) => { + if (key.return) { + onComplete(setLabel(widget, defaultLabel, text)); + } else if (key.escape) { + onCancel(); + } else if (key.tab) { + setText(defaultLabel); + } else if (key.backspace || key.delete) { + setText(dropLastGrapheme(text)); + } else if (shouldInsertInput(input, key)) { + setText(text + input); + } + }); + + // Quoted so trailing spaces, which usually separate the label from the + // value, stay visible + return ( + + Label + Type to edit, Backspace delete, Tab default, Enter save, ESC cancel + + " + {text} + + " + {` (default: "${defaultLabel}")`} + + + ); +}; diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index 93546a99e..a645f5005 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -163,4 +163,57 @@ 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(); + } + }); }); 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..6e5dc2646 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', () => { diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 1a9782adf..42b485ed3 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -54,6 +54,8 @@ import { toggleUsageInverted } from './shared/usage-display'; +const LABEL = 'Reset: '; + const BLOCK_RESET_PREVIEW_AT = '2026-03-12T08:30:00.000Z'; const USAGE_TIMER_LOADING_MESSAGE = '[Loading]'; @@ -62,6 +64,7 @@ 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(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -130,10 +133,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, LABEL, resetAt ?? (compact ? '03-12 08:30Z' : '2026-03-12 08:30 UTC')); } - return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m'); + return formatRawOrLabeledValue(item, LABEL, compact ? '4h30m' : '4hr 30m'); } const usageData = context.usageData ?? {}; @@ -148,7 +151,7 @@ export class BlockResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, 'Reset: ', USAGE_TIMER_LOADING_MESSAGE); + return formatRawOrLabeledValue(item, LABEL, USAGE_TIMER_LOADING_MESSAGE); } if (isUsageProgressMode(displayMode)) { @@ -172,12 +175,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, LABEL, resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact); - return formatRawOrLabeledValue(item, 'Reset: ', remainingTime); + return formatRawOrLabeledValue(item, LABEL, remainingTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 59beaace0..1c7c3d533 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -34,6 +34,8 @@ import { toggleUsageInverted } from './shared/usage-display'; +const 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 +43,7 @@ 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(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -88,7 +91,7 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m'); + return formatRawOrLabeledValue(item, LABEL, compact ? '3h45m' : '3hr 45m'); } const usageData = context.usageData ?? {}; @@ -114,7 +117,7 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m'); + return formatRawOrLabeledValue(item, LABEL, compact ? '0h' : '0hr 0m'); } if (isUsageProgressMode(displayMode)) { @@ -134,7 +137,7 @@ export class BlockTimerWidget implements Widget { } const elapsedTime = formatUsageDuration(window.elapsedMs, compact); - return formatRawOrLabeledValue(item, 'Block: ', elapsedTime); + return formatRawOrLabeledValue(item, LABEL, elapsedTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index c7b7fc526..627582d22 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, LABEL, 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, LABEL, 'n/a'); } const hitRate = getCacheHitRate(tokens); if (hitRate === null) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(0, format)); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, LABEL, formatPercent(0, format)); } if (hitRate === 0 && hideWhenEmpty) { return null; } - return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); + return formatRawOrLabeledValue(item, LABEL, formatPercent(hitRate, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index 0ac0a6c49..a643ee8a4 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, LABEL, 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, LABEL, '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, LABEL, value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheTimer.ts b/src/widgets/CacheTimer.ts index a51f8f85f..99d5355ae 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, LABEL, 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, LABEL, 'n/a'); } const state = getTranscriptState(transcriptPath); if (state.isWorking) { - return formatRawOrLabeledValue(item, 'Cache: ', withGlyph(getSlotSymbol(item, HOT_SLOT), 'HOT')); + return formatRawOrLabeledValue(item, LABEL, withGlyph(getSlotSymbol(item, HOT_SLOT), 'HOT')); } const { lastAssistant } = state; if (!lastAssistant) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache: ', 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, LABEL, '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, LABEL, withGlyph(glyph, formatCountdown(remaining))); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index f85b9b9c3..a561d215f 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, LABEL, 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, LABEL, '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, LABEL, value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/ClaudeAccountEmail.ts b/src/widgets/ClaudeAccountEmail.ts index aff428027..2d1c1298c 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, LABEL, 'you@example.com'); } try { @@ -34,7 +39,7 @@ export class ClaudeAccountEmailWidget implements Widget { return null; } - return item.rawValue ? email : `Account: ${email}`; + return formatRawOrLabeledValue(item, LABEL, email); } catch { return null; } diff --git a/src/widgets/ClaudeSessionId.ts b/src/widgets/ClaudeSessionId.ts index 27124be5e..10e587782 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, LABEL, 'preview-session-id'); } else { const sessionId = context.data?.session_id; if (!sessionId) { return null; } - return item.rawValue ? sessionId : `Session ID: ${sessionId}`; + return formatRawOrLabeledValue(item, LABEL, sessionId); } } diff --git a/src/widgets/ClaudeStatus.ts b/src/widgets/ClaudeStatus.ts index ee7bf0261..2357856e8 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 { diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 16e42fce1..59e5bb504 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, LABEL, sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`; + return formatRawOrLabeledValue(item, LABEL, 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, LABEL, sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? display : `Context: ${display}`; + return formatRawOrLabeledValue(item, LABEL, 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..a9019293e 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, LABEL, value); } const contextLengthTokens = getContextWindowContextLengthTokens(context.data); if (contextLengthTokens !== null) { - return item.rawValue ? formatTokens(contextLengthTokens, format) : `Ctx: ${formatTokens(contextLengthTokens, format)}`; + return formatRawOrLabeledValue(item, LABEL, formatTokens(contextLengthTokens, format)); } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.contextLength, format) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength, format)}`; + return formatRawOrLabeledValue(item, LABEL, 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..b89813faa 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, LABEL, 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, LABEL, formatTokens(total, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index bc76a4538..913430056 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 ? 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, LABEL, 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, LABEL, displayPath)}`; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 838ab59cd..357e7a367 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, LABEL, 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, LABEL, '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, LABEL, formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 8faaba7fd..9cf81b2f2 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, LABEL, 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, LABEL, '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, LABEL, formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 851b7c65f..5bb33ca91 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, LABEL, `[${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, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); + return formatRawOrLabeledValue(item, LABEL, 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, LABEL, '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, LABEL, `[${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, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); + return formatRawOrLabeledValue(item, LABEL, 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 6aa4485db..ae4307324 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, LABEL, 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, LABEL, value); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitStagedFiles.ts b/src/widgets/GitStagedFiles.ts index f3ea05398..00e1981a0 100644 --- a/src/widgets/GitStagedFiles.ts +++ b/src/widgets/GitStagedFiles.ts @@ -15,6 +15,9 @@ import { NO_GIT_HIDEABLE_STATE, isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'S:'; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the staged file count is zero' }; @@ -23,6 +26,7 @@ export class GitStagedFilesWidget implements Widget { getDescription(): string { return 'Shows count of staged files'; } getDisplayName(): string { return 'Git Staged Files'; } getCategory(): string { return 'Git'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -35,7 +39,7 @@ export class GitStagedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return item.rawValue ? '3' : 'S:3'; + return formatRawOrLabeledValue(item, LABEL, '3'); } if (!isInsideGitWorkTree(context)) { @@ -47,7 +51,7 @@ export class GitStagedFilesWidget implements Widget { return null; } - return item.rawValue ? `${counts.staged}` : `S:${counts.staged}`; + return formatRawOrLabeledValue(item, LABEL, `${counts.staged}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUnstagedFiles.ts b/src/widgets/GitUnstagedFiles.ts index b4296a9d4..37d1c0553 100644 --- a/src/widgets/GitUnstagedFiles.ts +++ b/src/widgets/GitUnstagedFiles.ts @@ -15,6 +15,9 @@ import { NO_GIT_HIDEABLE_STATE, isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = 'M:'; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the unstaged file count is zero' }; @@ -23,6 +26,7 @@ export class GitUnstagedFilesWidget implements Widget { getDescription(): string { return 'Shows count of unstaged files'; } getDisplayName(): string { return 'Git Unstaged Files'; } getCategory(): string { return 'Git'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -35,7 +39,7 @@ export class GitUnstagedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return item.rawValue ? '2' : 'M:2'; + return formatRawOrLabeledValue(item, LABEL, '2'); } if (!isInsideGitWorkTree(context)) { @@ -47,7 +51,7 @@ export class GitUnstagedFilesWidget implements Widget { return null; } - return item.rawValue ? `${counts.unstaged}` : `M:${counts.unstaged}`; + return formatRawOrLabeledValue(item, LABEL, `${counts.unstaged}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUntrackedFiles.ts b/src/widgets/GitUntrackedFiles.ts index ece236a10..a779ca534 100644 --- a/src/widgets/GitUntrackedFiles.ts +++ b/src/widgets/GitUntrackedFiles.ts @@ -15,6 +15,9 @@ import { NO_GIT_HIDEABLE_STATE, isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const LABEL = '?:'; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the untracked file count is zero' }; @@ -23,6 +26,7 @@ export class GitUntrackedFilesWidget implements Widget { getDescription(): string { return 'Shows count of untracked files'; } getDisplayName(): string { return 'Git Untracked Files'; } getCategory(): string { return 'Git'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -35,7 +39,7 @@ export class GitUntrackedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return item.rawValue ? '1' : '?:1'; + return formatRawOrLabeledValue(item, LABEL, '1'); } if (!isInsideGitWorkTree(context)) { @@ -47,7 +51,7 @@ export class GitUntrackedFilesWidget implements Widget { return null; } - return item.rawValue ? `${counts.untracked}` : `?:${counts.untracked}`; + return formatRawOrLabeledValue(item, LABEL, `${counts.untracked}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { 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..730596684 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, LABEL, '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, LABEL, 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..e7f31574a 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, LABEL, '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, LABEL, styleName); } return null; } diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index 5b104011c..a44420626 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, LABEL, '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, LABEL, formatted); } const duration = context.sessionDuration ?? '0m'; if ((duration === '0m' || duration === '<1m') && hideZero) { return null; } - return item.rawValue ? duration : `Session: ${duration}`; + return formatRawOrLabeledValue(item, LABEL, duration); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 07e336f00..d2985a58a 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, LABEL, 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, LABEL, formattedCost); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index f3bfb7e6d..9028d9dc4 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, LABEL, '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, LABEL, 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..7073fcdfc 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, LABEL, `${detectedWidth}`); } else if (width) { - return item.rawValue ? `${width}` : `Term: ${width}`; + return formatRawOrLabeledValue(item, LABEL, `${width}`); } return null; } diff --git a/src/widgets/ThinkingEffort.ts b/src/widgets/ThinkingEffort.ts index 0d5ea4eaa..91a2baf29 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, LABEL, 'high'); } const effort = formatEffort(resolveThinkingEffort(context)); - return item.rawValue ? effort : `Thinking: ${effort}`; + return formatRawOrLabeledValue(item, LABEL, effort); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index 237ac27ba..db7719afb 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -12,6 +12,8 @@ import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Cached: '; + const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; export class TokensCachedWidget implements Widget { @@ -19,6 +21,7 @@ export class TokensCachedWidget implements Widget { getDescription(): string { return 'Shows cached token count for the current session'; } getDisplayName(): string { return 'Tokens Cached'; } getCategory(): string { return 'Tokens'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -30,14 +33,14 @@ export class TokensCachedWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(12000, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(12000, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.cachedTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(context.tokenMetrics.cachedTokens, format)); } return null; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 1266deac2..e916b6edf 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -13,6 +13,8 @@ import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'In: '; + const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; export class TokensInputWidget implements Widget { @@ -20,6 +22,7 @@ export class TokensInputWidget implements Widget { getDescription(): string { return 'Shows input token count for the current session'; } getDisplayName(): string { return 'Tokens Input'; } getCategory(): string { return 'Tokens'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -31,7 +34,7 @@ export class TokensInputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(15200, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(15200, format)); } const inputTotalTokens = context.tokenMetrics?.inputTokens @@ -45,7 +48,7 @@ export class TokensInputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(inputTotalTokens, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 43dc46d4d..9b4f5299d 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -13,6 +13,8 @@ import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Out: '; + const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; export class TokensOutputWidget implements Widget { @@ -20,6 +22,7 @@ export class TokensOutputWidget implements Widget { getDescription(): string { return 'Shows output token count for the current session'; } getDisplayName(): string { return 'Tokens Output'; } getCategory(): string { return 'Tokens'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -31,7 +34,7 @@ export class TokensOutputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(3400, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(3400, format)); } const outputTotalTokens = context.tokenMetrics?.outputTokens @@ -45,7 +48,7 @@ export class TokensOutputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(outputTotalTokens, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index a780ad31e..3eeb640d6 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -12,6 +12,8 @@ import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const LABEL = 'Total: '; + const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; export class TokensTotalWidget implements Widget { @@ -19,6 +21,7 @@ export class TokensTotalWidget implements Widget { getDescription(): string { return 'Shows total token count (input + output + cache) for the current session'; } getDisplayName(): string { return 'Tokens Total'; } getCategory(): string { return 'Tokens'; } + getLabelPrefix(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName() }; } @@ -30,14 +33,14 @@ export class TokensTotalWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(30600, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(30600, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.totalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens, format)); + return formatRawOrLabeledValue(item, LABEL, formatTokens(context.tokenMetrics.totalTokens, format)); } return null; } 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..02ca1f9b6 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -62,6 +62,8 @@ import { toggleUsageWeekday } from './shared/usage-display'; +const 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 +134,7 @@ 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(): string { return LABEL; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -214,10 +217,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, LABEL, resetAt ?? fallback); } - return formatRawOrLabeledValue(item, 'Weekly Reset: ', formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); + return formatRawOrLabeledValue(item, LABEL, formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); } const usageData = context.usageData ?? {}; @@ -232,7 +235,7 @@ export class WeeklyResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, 'Weekly Reset: ', USAGE_TIMER_LOADING_MESSAGE); + return formatRawOrLabeledValue(item, LABEL, USAGE_TIMER_LOADING_MESSAGE); } if (isUsageProgressMode(displayMode)) { @@ -256,12 +259,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, LABEL, resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact, useDays); - return formatRawOrLabeledValue(item, 'Weekly Reset: ', remainingTime); + return formatRawOrLabeledValue(item, LABEL, 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__/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__/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/shared/__tests__/raw-or-labeled.test.ts b/src/widgets/shared/__tests__/raw-or-labeled.test.ts new file mode 100644 index 000000000..74ba4b80b --- /dev/null +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -0,0 +1,60 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../../types/Widget'; +import { + 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(), 'Model: ', 'M '); + expect(item.metadata).toEqual({ label: 'M ' }); + expect(getLabel(item, 'Model: ')).toBe('M '); + }); + + it('keeps an empty override', () => { + expect(setLabel(makeItem(), 'Model: ', '').metadata).toEqual({ label: '' }); + }); + + it('drops the override when it matches the default, preserving other metadata', () => { + const item = makeItem({ metadata: { label: 'M ', hide: 'zero' } }); + expect(setLabel(item, 'Model: ', 'Model: ').metadata).toEqual({ hide: 'zero' }); + expect(setLabel(makeItem({ metadata: { label: 'M ' } }), 'Model: ', 'Model: ').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(); + }); +}); diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 94d4d847c..2c095e866 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -1,5 +1,51 @@ -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; +} + +// An override matching the default is dropped so untouched items stay minimal. +// An empty override is kept: it means "no label" rather than "default". +export function setLabel(item: WidgetItem, defaultLabel: string, value: string): WidgetItem { + if (value === defaultLabel) { + return removeMetadataKeys(item, [LABEL_METADATA_KEY]); + } + + 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: "${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/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; } From 4a141ee72c2bf01f05d06f16a7d2ac71e43eb501 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Thu, 24 Sep 2026 11:07:54 -0400 Subject: [PATCH 02/15] refactor(tui): share grapheme-aware text cursor between editors Extract CustomText's inline cursor handling into useTextCursor (src/widgets/shared/text-cursor.ts) and use it from both the Custom Text and label editors, giving the label editor cursor movement and forward delete. Ctrl+Left/Right now jump to the start/end as the editor hint says. The old checks compared input to 'ArrowLeft', which Ink never sends, and ran after the plain-arrow branch, so they were unreachable. Co-Authored-By: Claude Opus 5.5 --- src/tui/components/LabelEditor.tsx | 26 ++-- src/widgets/CustomText.tsx | 115 +----------------- .../shared/__tests__/text-cursor.test.ts | 80 ++++++++++++ src/widgets/shared/text-cursor.ts | 96 +++++++++++++++ 4 files changed, 189 insertions(+), 128 deletions(-) create mode 100644 src/widgets/shared/__tests__/text-cursor.test.ts create mode 100644 src/widgets/shared/text-cursor.ts diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx index 8f109f2f9..51bfe5f40 100644 --- a/src/tui/components/LabelEditor.tsx +++ b/src/tui/components/LabelEditor.tsx @@ -3,14 +3,14 @@ import { Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import React from 'react'; import type { WidgetItem } from '../../types/Widget'; -import { shouldInsertInput } from '../../utils/input-guards'; import { getLabel, setLabel } from '../../widgets/shared/raw-or-labeled'; +import { useTextCursor } from '../../widgets/shared/text-cursor'; export interface LabelEditorProps { widget: WidgetItem; @@ -19,15 +19,8 @@ export interface LabelEditorProps { onCancel: () => void; } -function dropLastGrapheme(str: string): string { - const graphemes = 'Segmenter' in Intl - ? Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(str), seg => seg.segment) - : Array.from(str); - return graphemes.slice(0, -1).join(''); -} - export const LabelEditor: React.FC = ({ widget, defaultLabel, onComplete, onCancel }) => { - const [text, setText] = useState(() => getLabel(widget, defaultLabel)); + const { text, display, setText, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); useInput((input, key) => { if (key.return) { @@ -36,10 +29,8 @@ export const LabelEditor: React.FC = ({ widget, defaultLabel, onCancel(); } else if (key.tab) { setText(defaultLabel); - } else if (key.backspace || key.delete) { - setText(dropLastGrapheme(text)); - } else if (shouldInsertInput(input, key)) { - setText(text + input); + } else { + handleInput(input, key); } }); @@ -48,12 +39,9 @@ export const LabelEditor: React.FC = ({ widget, defaultLabel, return ( Label - Type to edit, Backspace delete, Tab default, Enter save, ESC cancel + ←→ move cursor, Ctrl+←→ jump to start/end, Tab default, Enter save, ESC cancel - " - {text} - - " + {`"${display}"`} {` (default: "${defaultLabel}")`} diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 03ba01724..ef3b2afc9 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 { text, display, handleInput } = useTextCursor(widget.customText ?? ''); useInput((input, key) => { if (key.return) { onComplete({ ...widget, customText: text }); } 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/shared/__tests__/text-cursor.test.ts b/src/widgets/shared/__tests__/text-cursor.test.ts new file mode 100644 index 000000000..8937ddda0 --- /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[0m`); + }); + + it('draws a trailing block when the cursor is at the end', () => { + expect(renderTextWithCursor({ text: 'ab', cursor: 2 })).toBe('ab\x1b[7m \x1b[0m'); + expect(renderTextWithCursor({ text: '', cursor: 0 })).toBe('\x1b[7m \x1b[0m'); + }); +}); diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts new file mode 100644 index 000000000..28bac4bdf --- /dev/null +++ b/src/widgets/shared/text-cursor.ts @@ -0,0 +1,96 @@ +import type { Key } from 'ink'; +import { 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; +} + +function 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); +} + +// String offsets of every grapheme boundary, from 0 through text.length +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 { + const boundaries = getBoundaries(text); + const index = boundaries.findIndex(boundary => boundary >= cursor); + const graphemes = getGraphemes(text); + + return graphemes + .map((grapheme, i) => (i === index ? `\x1b[7m${grapheme}\x1b[0m` : grapheme)) + .join('') + (index >= graphemes.length ? '\x1b[7m \x1b[0m' : ''); +} + +export function useTextCursor(initialText: string) { + const [state, setState] = useState({ text: initialText, cursor: initialText.length }); + + return { + text: state.text, + display: renderTextWithCursor(state), + // Replaces the text and moves the cursor to its end + setText: (text: string) => { setState({ text, cursor: text.length }); }, + // Returns whether the key was consumed, so callers can fall through + // to their own bindings + handleInput: (input: string, key: Key): boolean => { + const next = applyTextCursorInput(state, input, key); + if (next) { + setState(next); + } + return next !== null; + } + }; +} From 2278a3c69c061512143d3d4662949cb0ca048885 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Thu, 24 Sep 2026 11:13:05 -0400 Subject: [PATCH 03/15] docs(usage): document the widget label override Co-Authored-By: Claude Opus 5.5 --- docs/USAGE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/USAGE.md b/docs/USAGE.md index 62cd88bcf..51cead8ce 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 restores 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. 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) From 4c3e3062b19503c599c5db285b6b3671c0406da9 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:24:59 -0400 Subject: [PATCH 04/15] fix(widgets): render labels from getLabelPrefix so the default can't drift Block, Block Reset and Weekly Reset timers render a colon-less label in their bar modes, but getLabelPrefix always returned the colon form, so the label editor offered the wrong default and an override equal to the bar label was never saved. getLabelPrefix now returns the bar-mode label in those modes, and every widget's render takes its label from getLabelPrefix rather than repeating it. Co-Authored-By: Claude Opus 5.5 --- src/utils/__tests__/widgets.test.ts | 34 ++++++++++++++++++++++++++++ src/widgets/BlockResetTimer.ts | 24 ++++++++++++-------- src/widgets/BlockTimer.ts | 24 ++++++++++++-------- src/widgets/CacheHitRate.ts | 8 +++---- src/widgets/CacheRead.ts | 6 ++--- src/widgets/CacheTimer.ts | 10 ++++---- src/widgets/CacheWrite.ts | 6 ++--- src/widgets/ClaudeAccountEmail.ts | 4 ++-- src/widgets/ClaudeSessionId.ts | 4 ++-- src/widgets/ClaudeStatus.ts | 12 +++++----- src/widgets/ContextBar.ts | 8 +++---- src/widgets/ContextLength.ts | 6 ++--- src/widgets/ContextWindow.ts | 4 ++-- src/widgets/CurrentWorkingDir.tsx | 4 ++-- src/widgets/ExtraUsageRemaining.ts | 6 ++--- src/widgets/ExtraUsageUsed.ts | 6 ++--- src/widgets/ExtraUsageUtilization.ts | 14 ++++++------ src/widgets/FreeMemory.ts | 4 ++-- src/widgets/GitStagedFiles.ts | 4 ++-- src/widgets/GitUnstagedFiles.ts | 4 ++-- src/widgets/GitUntrackedFiles.ts | 4 ++-- src/widgets/Model.ts | 4 ++-- src/widgets/OutputStyle.ts | 4 ++-- src/widgets/SessionClock.ts | 6 ++--- src/widgets/SessionCost.ts | 4 ++-- src/widgets/SessionName.ts | 4 ++-- src/widgets/TerminalWidth.ts | 4 ++-- src/widgets/ThinkingEffort.ts | 4 ++-- src/widgets/TokensCached.ts | 4 ++-- src/widgets/TokensInput.ts | 4 ++-- src/widgets/TokensOutput.ts | 4 ++-- src/widgets/TokensTotal.ts | 4 ++-- src/widgets/WeeklyResetTimer.ts | 24 ++++++++++++-------- 33 files changed, 156 insertions(+), 110 deletions(-) diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 6e5dc2646..da77b2605 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -368,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 42b485ed3..15575c88d 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -55,6 +55,7 @@ import { } 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]'; @@ -64,7 +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(): string { return LABEL; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -114,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)) { @@ -122,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) { @@ -133,10 +137,10 @@ export class BlockResetTimerWidget implements Widget { getUsageLocale(item), isUsage12HourClock(item) ); - return formatRawOrLabeledValue(item, LABEL, 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, LABEL, compact ? '4h30m' : '4hr 30m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), compact ? '4h30m' : '4hr 30m'); } const usageData = context.usageData ?? {}; @@ -151,14 +155,14 @@ export class BlockResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, LABEL, 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)) { @@ -167,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) { @@ -175,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, LABEL, resetAt); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact); - return formatRawOrLabeledValue(item, LABEL, 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 1c7c3d533..af07aa185 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -35,6 +35,7 @@ import { } 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' }; @@ -43,7 +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(): string { return LABEL; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -80,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)) { @@ -88,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, LABEL, compact ? '3h45m' : '3hr 45m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), compact ? '3h45m' : '3hr 45m'); } const usageData = context.usageData ?? {}; @@ -106,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)) { @@ -114,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, LABEL, 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)) { @@ -133,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, LABEL, 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 627582d22..7c0dbc760 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -49,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, LABEL, 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, LABEL, 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } const hitRate = getCacheHitRate(tokens); if (hitRate === null) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, LABEL, formatPercent(0, format)); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(0, format)); } if (hitRate === 0 && hideWhenEmpty) { return null; } - return formatRawOrLabeledValue(item, LABEL, 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 a643ee8a4..cdcc2af61 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -49,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, LABEL, 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, LABEL, 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (tokens.read === 0 && hideWhenEmpty) { @@ -63,7 +63,7 @@ export class CacheReadWidget implements Widget { } const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheTimer.ts b/src/widgets/CacheTimer.ts index 99d5355ae..1eb8aae45 100644 --- a/src/widgets/CacheTimer.ts +++ b/src/widgets/CacheTimer.ts @@ -270,30 +270,30 @@ export class CacheTimerWidget implements Widget { const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 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, LABEL, 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } const state = getTranscriptState(transcriptPath); if (state.isWorking) { - return formatRawOrLabeledValue(item, LABEL, 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, LABEL, '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, LABEL, 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 a561d215f..1d4cd16cf 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -49,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, LABEL, 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, LABEL, 'n/a'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (tokens.creation === 0 && hideWhenEmpty) { @@ -63,7 +63,7 @@ export class CacheWriteWidget implements Widget { } const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/ClaudeAccountEmail.ts b/src/widgets/ClaudeAccountEmail.ts index 2d1c1298c..f3f190061 100644 --- a/src/widgets/ClaudeAccountEmail.ts +++ b/src/widgets/ClaudeAccountEmail.ts @@ -27,7 +27,7 @@ export class ClaudeAccountEmailWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 'you@example.com'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'you@example.com'); } try { @@ -39,7 +39,7 @@ export class ClaudeAccountEmailWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, email); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), email); } catch { return null; } diff --git a/src/widgets/ClaudeSessionId.ts b/src/widgets/ClaudeSessionId.ts index 10e587782..3c9291ccb 100644 --- a/src/widgets/ClaudeSessionId.ts +++ b/src/widgets/ClaudeSessionId.ts @@ -22,13 +22,13 @@ export class ClaudeSessionIdWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 'preview-session-id'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'preview-session-id'); } else { const sessionId = context.data?.session_id; if (!sessionId) { return null; } - return formatRawOrLabeledValue(item, LABEL, sessionId); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sessionId); } } diff --git a/src/widgets/ClaudeStatus.ts b/src/widgets/ClaudeStatus.ts index 2357856e8..d59404b28 100644 --- a/src/widgets/ClaudeStatus.ts +++ b/src/widgets/ClaudeStatus.ts @@ -91,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}`; } @@ -103,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 59e5bb504..5079e752f 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -97,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 formatRawOrLabeledValue(item, LABEL, sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return formatRawOrLabeledValue(item, LABEL, previewDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), previewDisplay); } const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -131,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 formatRawOrLabeledValue(item, LABEL, sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return formatRawOrLabeledValue(item, LABEL, display); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), display); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index a9019293e..fde94bd3b 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -27,16 +27,16 @@ export class ContextLengthWidget implements Widget { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { const value = formatTokens(18600, format); - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const contextLengthTokens = getContextWindowContextLengthTokens(context.data); if (contextLengthTokens !== null) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(contextLengthTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(contextLengthTokens, format)); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(context.tokenMetrics.contextLength, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(context.tokenMetrics.contextLength, format)); } return null; } diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index b89813faa..891e41f59 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -31,7 +31,7 @@ export class ContextWindowWidget implements Widget { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { const value = formatTokens(200000, format); - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } let total = getContextWindowSize(context.data); @@ -45,7 +45,7 @@ export class ContextWindowWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, 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 913430056..6feb66ffd 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -139,7 +139,7 @@ export class CurrentWorkingDirWidget implements Widget { previewPath = '/Users/example/Documents/Projects/my-project'; } - return `${symbolPrefix}${formatRawOrLabeledValue(item, LABEL, previewPath)}`; + return `${symbolPrefix}${formatRawOrLabeledValue(item, this.getLabelPrefix(), previewPath)}`; } const cwd = context.data?.cwd; @@ -176,7 +176,7 @@ export class CurrentWorkingDirWidget implements Widget { } } - return `${symbolPrefix}${formatRawOrLabeledValue(item, LABEL, displayPath)}`; + return `${symbolPrefix}${formatRawOrLabeledValue(item, this.getLabelPrefix(), displayPath)}`; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 357e7a367..2909de361 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -35,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, LABEL, 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, LABEL, 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageLimit === undefined || data.extraUsageUsed === undefined) { if (data.error) { @@ -59,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, LABEL, formatted); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 9cf81b2f2..4f6e1374c 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -35,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, LABEL, 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, LABEL, 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUsed === undefined) { if (data.error) { @@ -57,7 +57,7 @@ export class ExtraUsageUsedWidget implements Widget { const usedDollars = data.extraUsageUsed / 100; const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format); - return formatRawOrLabeledValue(item, LABEL, formatted); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 5bb33ca91..563b4dfef 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -75,23 +75,23 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, LABEL, `[${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, LABEL, sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, 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, LABEL, 'n/a'); + : formatRawOrLabeledValue(item, this.getLabelPrefix(), 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUtilization === undefined) { if (data.error) { @@ -109,16 +109,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, LABEL, `[${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, LABEL, sliderDisplay); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index ae4307324..61413f5a4 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -84,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 formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const total = os.totalmem(); @@ -100,7 +100,7 @@ export class FreeMemoryWidget implements Widget { const value = `${formatBytes(used, format)}/${formatBytes(total, format)}`; - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitStagedFiles.ts b/src/widgets/GitStagedFiles.ts index 00e1981a0..880d54edb 100644 --- a/src/widgets/GitStagedFiles.ts +++ b/src/widgets/GitStagedFiles.ts @@ -39,7 +39,7 @@ export class GitStagedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, '3'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '3'); } if (!isInsideGitWorkTree(context)) { @@ -51,7 +51,7 @@ export class GitStagedFilesWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, `${counts.staged}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${counts.staged}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUnstagedFiles.ts b/src/widgets/GitUnstagedFiles.ts index 37d1c0553..1ab10824d 100644 --- a/src/widgets/GitUnstagedFiles.ts +++ b/src/widgets/GitUnstagedFiles.ts @@ -39,7 +39,7 @@ export class GitUnstagedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, '2'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '2'); } if (!isInsideGitWorkTree(context)) { @@ -51,7 +51,7 @@ export class GitUnstagedFilesWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, `${counts.unstaged}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${counts.unstaged}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUntrackedFiles.ts b/src/widgets/GitUntrackedFiles.ts index a779ca534..d02385297 100644 --- a/src/widgets/GitUntrackedFiles.ts +++ b/src/widgets/GitUntrackedFiles.ts @@ -39,7 +39,7 @@ export class GitUntrackedFilesWidget implements Widget { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, '1'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '1'); } if (!isInsideGitWorkTree(context)) { @@ -51,7 +51,7 @@ export class GitUntrackedFilesWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, `${counts.untracked}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${counts.untracked}`); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index 730596684..ea6b650b6 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -22,7 +22,7 @@ export class ModelWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 'Claude'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'Claude'); } const model = context.data?.model; @@ -32,7 +32,7 @@ export class ModelWidget implements Widget { if (modelDisplayName) { const shortName = modelDisplayName.replace(/\s*\(.*\)$/, ''); - return formatRawOrLabeledValue(item, LABEL, shortName); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), shortName); } return null; } diff --git a/src/widgets/OutputStyle.ts b/src/widgets/OutputStyle.ts index e7f31574a..7c586a008 100644 --- a/src/widgets/OutputStyle.ts +++ b/src/widgets/OutputStyle.ts @@ -30,13 +30,13 @@ export class OutputStyleWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, '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 formatRawOrLabeledValue(item, LABEL, styleName); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), styleName); } return null; } diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index a44420626..4b3ad3523 100644 --- a/src/widgets/SessionClock.ts +++ b/src/widgets/SessionClock.ts @@ -50,7 +50,7 @@ export class SessionClockWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, '2hr 15m'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), '2hr 15m'); } const hideZero = isHidden(item, ZERO_HIDEABLE_STATE.key); @@ -61,14 +61,14 @@ export class SessionClockWidget implements Widget { return null; } const formatted = formatDurationFromMs(durationMs); - return formatRawOrLabeledValue(item, LABEL, formatted); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatted); } const duration = context.sessionDuration ?? '0m'; if ((duration === '0m' || duration === '<1m') && hideZero) { return null; } - return formatRawOrLabeledValue(item, LABEL, duration); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), duration); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index d2985a58a..bf97b2b9a 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -36,7 +36,7 @@ export class SessionCostWidget implements Widget { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { const value = formatCost(2.45, format); - return formatRawOrLabeledValue(item, LABEL, value); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), value); } const totalCost = context.data?.cost?.total_cost_usd; @@ -52,7 +52,7 @@ export class SessionCostWidget implements Widget { } const formattedCost = formatCost(totalCost, format); - return formatRawOrLabeledValue(item, LABEL, formattedCost); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formattedCost); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index 9028d9dc4..5604b4119 100644 --- a/src/widgets/SessionName.ts +++ b/src/widgets/SessionName.ts @@ -23,7 +23,7 @@ export class SessionNameWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 'my-session'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'my-session'); } const sessionName = context.transcriptSessionName === undefined @@ -33,7 +33,7 @@ export class SessionNameWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, sessionName); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), sessionName); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TerminalWidth.ts b/src/widgets/TerminalWidth.ts index 7073fcdfc..b088895c1 100644 --- a/src/widgets/TerminalWidth.ts +++ b/src/widgets/TerminalWidth.ts @@ -25,9 +25,9 @@ export class TerminalWidthWidget implements Widget { const width = context.terminalWidth ?? getTerminalWidth(); if (context.isPreview) { const detectedWidth = width ?? '??'; - return formatRawOrLabeledValue(item, LABEL, `${detectedWidth}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${detectedWidth}`); } else if (width) { - return formatRawOrLabeledValue(item, LABEL, `${width}`); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), `${width}`); } return null; } diff --git a/src/widgets/ThinkingEffort.ts b/src/widgets/ThinkingEffort.ts index 91a2baf29..eb28fb0c6 100644 --- a/src/widgets/ThinkingEffort.ts +++ b/src/widgets/ThinkingEffort.ts @@ -73,11 +73,11 @@ export class ThinkingEffortWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, 'high'); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), 'high'); } const effort = formatEffort(resolveThinkingEffort(context)); - return formatRawOrLabeledValue(item, LABEL, effort); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), effort); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index db7719afb..7b575c1d6 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -33,14 +33,14 @@ export class TokensCachedWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(12000, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(12000, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.cachedTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, LABEL, formatTokens(context.tokenMetrics.cachedTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(context.tokenMetrics.cachedTokens, format)); } return null; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index e916b6edf..49b6ed68d 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -34,7 +34,7 @@ export class TokensInputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(15200, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(15200, format)); } const inputTotalTokens = context.tokenMetrics?.inputTokens @@ -48,7 +48,7 @@ export class TokensInputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, formatTokens(inputTotalTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(inputTotalTokens, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 9b4f5299d..ac3511c4b 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -34,7 +34,7 @@ export class TokensOutputWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(3400, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(3400, format)); } const outputTotalTokens = context.tokenMetrics?.outputTokens @@ -48,7 +48,7 @@ export class TokensOutputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, LABEL, formatTokens(outputTotalTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(outputTotalTokens, format)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 3eeb640d6..9903a6ea6 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -33,14 +33,14 @@ export class TokensTotalWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, LABEL, formatTokens(30600, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(30600, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.totalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, LABEL, formatTokens(context.tokenMetrics.totalTokens, format)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(), formatTokens(context.tokenMetrics.totalTokens, format)); } return null; } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 02ca1f9b6..8803c20a1 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -63,6 +63,7 @@ import { } 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'; @@ -134,7 +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(): string { return LABEL; } + getLabelPrefix(item: WidgetItem): string { + const displayMode = getUsageDisplayMode(item); + return isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode) ? BAR_LABEL : LABEL; + } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { @@ -193,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)) { @@ -201,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) { @@ -217,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, LABEL, resetAt ?? fallback); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt ?? fallback); } - return formatRawOrLabeledValue(item, LABEL, formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); } const usageData = context.usageData ?? {}; @@ -235,14 +239,14 @@ export class WeeklyResetTimerWidget implements Widget { return getUsageErrorMessage(usageData.error); } - return formatRawOrLabeledValue(item, LABEL, 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)) { @@ -251,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) { @@ -259,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, LABEL, resetAt); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), resetAt); } } const remainingTime = formatUsageDuration(window.remainingMs, compact, useDays); - return formatRawOrLabeledValue(item, LABEL, remainingTime); + return formatRawOrLabeledValue(item, this.getLabelPrefix(item), remainingTime); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { From c95dd201cef412d706db9541b52d4211d5fbfb50 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:27:01 -0400 Subject: [PATCH 05/15] fix(tui): drop a widget's label when its type changes Changing a widget's type keeps its metadata, so a label written for one widget showed up on the next, or lingered hidden on a type without a label editor. Co-Authored-By: Claude Opus 5.5 --- src/tui/components/ItemsEditor.tsx | 4 +- .../components/__tests__/ItemsEditor.test.ts | 42 +++++++++++++++++++ src/widgets/shared/raw-or-labeled.ts | 6 ++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 0c7bbb55d..175e0e301 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -33,6 +33,7 @@ import { } from '../../widgets/shared/hideable'; import { EDIT_LABEL_ACTION, + clearLabel, getLabelKeybind, getLabelModifierText } from '../../widgets/shared/raw-or-labeled'; @@ -174,7 +175,8 @@ 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] = { ...clearLabel(currentWidget), type: selectedType }; onUpdate(newWidgets); } } else { diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index a645f5005..4ea0be5c2 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -216,4 +216,46 @@ describe('ItemsEditor', () => { stderr.destroy(); } }); + + it('drops the label when the widget type changes', async () => { + 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 'session cost') { + stdin.write(char); + await flushInk(); + } + stdout.clearOutput(); + stdin.write('\r'); + await flushInk(); + const output = stripAnsi(stdout.getOutput()); + expect(output).toContain('1. Session Cost'); + expect(output).not.toContain('(label:'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 2c095e866..a2879b916 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -21,11 +21,15 @@ 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]); +} + // An override matching the default is dropped so untouched items stay minimal. // An empty override is kept: it means "no label" rather than "default". export function setLabel(item: WidgetItem, defaultLabel: string, value: string): WidgetItem { if (value === defaultLabel) { - return removeMetadataKeys(item, [LABEL_METADATA_KEY]); + return clearLabel(item); } return { From 3b16ee41731e0a7a8079d74f8af5b8d7e7706794 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:28:00 -0400 Subject: [PATCH 06/15] fix(tui): keep keys typed before a re-render in text editors useTextCursor applied each key to the state captured at render, so two keys handled before a re-render edited the same snapshot and one was lost. Edits now go through a functional state update. Co-Authored-By: Claude Opus 5.5 --- .../components/__tests__/ItemsEditor.test.ts | 40 +++++++++++++++++++ src/widgets/shared/text-cursor.ts | 11 +++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index 4ea0be5c2..f5ec631f5 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -258,4 +258,44 @@ describe('ItemsEditor', () => { 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(); + } + }); }); diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts index 28bac4bdf..5561a4153 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -86,11 +86,14 @@ export function useTextCursor(initialText: string) { // Returns whether the key was consumed, so callers can fall through // to their own bindings handleInput: (input: string, key: Key): boolean => { - const next = applyTextCursorInput(state, input, key); - if (next) { - setState(next); + // Whether a key is consumed never depends on the text, so the + // render-time snapshot can answer it. The edit itself must apply + // to the latest state, or keys arriving before a re-render drop. + if (applyTextCursorInput(state, input, key) === null) { + return false; } - return next !== null; + setState(prev => applyTextCursorInput(prev, input, key) ?? prev); + return true; } }; } From d811308debf8510e682c83c825078a467119d210 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:29:19 -0400 Subject: [PATCH 07/15] fix(tui): keep emoji labels whole in the label editor The label and its default sat in sibling Texts. Ink measures a toned or joined emoji as several columns, so the default overwrote the end of such a label on screen. Both now share one Text. Co-Authored-By: Claude Opus 5.5 --- src/tui/components/LabelEditor.tsx | 11 ++++--- .../components/__tests__/ItemsEditor.test.ts | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx index 51bfe5f40..cd0242a4c 100644 --- a/src/tui/components/LabelEditor.tsx +++ b/src/tui/components/LabelEditor.tsx @@ -35,14 +35,17 @@ export const LabelEditor: React.FC = ({ widget, defaultLabel, }); // Quoted so trailing spaces, which usually separate the label from the - // value, stay visible + // 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 default, Enter save, ESC cancel - - {`"${display}"`} - {` (default: "${defaultLabel}")`} + + + {`"${display}"`} + {` (default: "${defaultLabel}")`} + ); diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index f5ec631f5..f5b829b6d 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -298,4 +298,36 @@ describe('ItemsEditor', () => { 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(); + } + }); }); From 0124311c9a3fb2596122faf2b614577717ce1f25 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:29:56 -0400 Subject: [PATCH 08/15] refactor(widgets): share getGraphemes with the symbol editors Custom Symbol and the symbol override editor each kept their own copy of the Intl.Segmenter split that text-cursor already has. Co-Authored-By: Claude Opus 5.5 --- src/widgets/CustomSymbol.tsx | 19 ++----------------- src/widgets/shared/symbol-override.tsx | 19 ++----------------- src/widgets/shared/text-cursor.ts | 2 +- 3 files changed, 5 insertions(+), 35 deletions(-) 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/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 index 5561a4153..94a1bdb69 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -10,7 +10,7 @@ export interface TextCursorState { cursor: number; } -function getGraphemes(str: string): string[] { +export function 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); From 84a0c6414a85441584e8c32f637a91daf65dcd52 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:30:46 -0400 Subject: [PATCH 09/15] perf(tui): reuse one segmenter and split once when drawing the cursor renderTextWithCursor split the text twice per render, and every split built a new Intl.Segmenter. Co-Authored-By: Claude Opus 5.5 --- src/widgets/shared/text-cursor.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts index 94a1bdb69..72cfe69ad 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -10,9 +10,10 @@ export interface TextCursorState { cursor: number; } +const segmenter = 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; + export function getGraphemes(str: string): string[] { - if ('Segmenter' in Intl) { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + if (segmenter) { return Array.from(segmenter.segment(str), seg => seg.segment); } // Fallback to simple character array (won't handle complex emojis perfectly) @@ -66,13 +67,16 @@ export function applyTextCursorInput(state: TextCursorState, input: string, key: /** The text with the grapheme under the cursor in inverse video (a trailing block at the end). */ export function renderTextWithCursor({ text, cursor }: TextCursorState): string { - const boundaries = getBoundaries(text); - const index = boundaries.findIndex(boundary => boundary >= cursor); - const graphemes = getGraphemes(text); + let offset = 0; + const rendered = getGraphemes(text) + .map((grapheme) => { + const underCursor = offset === cursor; + offset += grapheme.length; + return underCursor ? `\x1b[7m${grapheme}\x1b[0m` : grapheme; + }) + .join(''); - return graphemes - .map((grapheme, i) => (i === index ? `\x1b[7m${grapheme}\x1b[0m` : grapheme)) - .join('') + (index >= graphemes.length ? '\x1b[7m \x1b[0m' : ''); + return cursor === text.length ? `${rendered}\x1b[7m \x1b[0m` : rendered; } export function useTextCursor(initialText: string) { From f1ad5c1a9d865d78d6c1907d266e5f6651df8fae Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:31:17 -0400 Subject: [PATCH 10/15] fix(tui): end only the inverse after the text cursor \x1b[0m reset every attribute, so any styling around the edited text stopped at the cursor. Co-Authored-By: Claude Opus 5.5 --- src/widgets/shared/__tests__/text-cursor.test.ts | 6 +++--- src/widgets/shared/text-cursor.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/widgets/shared/__tests__/text-cursor.test.ts b/src/widgets/shared/__tests__/text-cursor.test.ts index 8937ddda0..9f97031e6 100644 --- a/src/widgets/shared/__tests__/text-cursor.test.ts +++ b/src/widgets/shared/__tests__/text-cursor.test.ts @@ -70,11 +70,11 @@ describe('applyTextCursorInput', () => { describe('renderTextWithCursor', () => { it('inverts the grapheme under the cursor', () => { - expect(renderTextWithCursor({ text: `a${FAMILY}`, cursor: 1 })).toBe(`a\x1b[7m${FAMILY}\x1b[0m`); + 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[0m'); - expect(renderTextWithCursor({ text: '', cursor: 0 })).toBe('\x1b[7m \x1b[0m'); + 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/text-cursor.ts b/src/widgets/shared/text-cursor.ts index 72cfe69ad..abbb83d7b 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -72,11 +72,11 @@ export function renderTextWithCursor({ text, cursor }: TextCursorState): string .map((grapheme) => { const underCursor = offset === cursor; offset += grapheme.length; - return underCursor ? `\x1b[7m${grapheme}\x1b[0m` : grapheme; + return underCursor ? `\x1b[7m${grapheme}\x1b[27m` : grapheme; }) .join(''); - return cursor === text.length ? `${rendered}\x1b[7m \x1b[0m` : rendered; + return cursor === text.length ? `${rendered}\x1b[7m \x1b[27m` : rendered; } export function useTextCursor(initialText: string) { From 12ee84d2fa824f1488c1f4ddf96432c8a8f5ce2d Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 10:31:54 -0400 Subject: [PATCH 11/15] fix(tui): escape quotes when showing a label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (label: …) modifier and the editor's default wrapped the label in quotes without escaping it, so a label containing " read ambiguously. Co-Authored-By: Claude Opus 5.5 --- src/tui/components/LabelEditor.tsx | 2 +- src/widgets/shared/__tests__/raw-or-labeled.test.ts | 4 ++++ src/widgets/shared/raw-or-labeled.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx index cd0242a4c..c9ab7be6c 100644 --- a/src/tui/components/LabelEditor.tsx +++ b/src/tui/components/LabelEditor.tsx @@ -44,7 +44,7 @@ export const LabelEditor: React.FC = ({ widget, defaultLabel, {`"${display}"`} - {` (default: "${defaultLabel}")`} + {` (default: ${JSON.stringify(defaultLabel)})`} diff --git a/src/widgets/shared/__tests__/raw-or-labeled.test.ts b/src/widgets/shared/__tests__/raw-or-labeled.test.ts index 74ba4b80b..212710592 100644 --- a/src/widgets/shared/__tests__/raw-or-labeled.test.ts +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -57,4 +57,8 @@ describe('getLabelModifierText', () => { 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/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index a2879b916..41d766ca4 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -51,5 +51,5 @@ export function getLabelKeybind(): CustomKeybind { export function getLabelModifierText(item: WidgetItem): string | undefined { const label = item.metadata?.[LABEL_METADATA_KEY]; - return label === undefined ? undefined : `(label: "${label}")`; + return label === undefined ? undefined : `(label: ${JSON.stringify(label)})`; } From 9b63f69445d7978f4671c1b8c040f068e692468d Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 11:20:20 -0400 Subject: [PATCH 12/15] fix(tui): keep a label that matches the current mode's default setLabel dropped an override equal to the default for the widget's current mode, so a widget whose default changes with its mode could not pin that label for every mode (e.g. "Block " set in a bar mode still rendered "Block: " in time mode). Enter now always saves the typed label, and Tab clears the override instead of filling in the default. Co-Authored-By: Claude Opus 5.5 --- docs/USAGE.md | 2 +- src/tui/components/LabelEditor.tsx | 9 ++-- .../components/__tests__/ItemsEditor.test.ts | 41 +++++++++++++++++++ .../shared/__tests__/raw-or-labeled.test.ts | 18 +++++--- src/widgets/shared/raw-or-labeled.ts | 11 ++--- src/widgets/shared/text-cursor.ts | 2 - 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 51cead8ce..b8d9631f9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -226,7 +226,7 @@ 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 restores 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. 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. +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 diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx index c9ab7be6c..a00be027b 100644 --- a/src/tui/components/LabelEditor.tsx +++ b/src/tui/components/LabelEditor.tsx @@ -7,6 +7,7 @@ import React from 'react'; import type { WidgetItem } from '../../types/Widget'; import { + clearLabel, getLabel, setLabel } from '../../widgets/shared/raw-or-labeled'; @@ -20,15 +21,15 @@ export interface LabelEditorProps { } export const LabelEditor: React.FC = ({ widget, defaultLabel, onComplete, onCancel }) => { - const { text, display, setText, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); + const { text, display, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); useInput((input, key) => { if (key.return) { - onComplete(setLabel(widget, defaultLabel, text)); + onComplete(setLabel(widget, text)); } else if (key.escape) { onCancel(); } else if (key.tab) { - setText(defaultLabel); + onComplete(clearLabel(widget)); } else { handleInput(input, key); } @@ -40,7 +41,7 @@ export const LabelEditor: React.FC = ({ widget, defaultLabel, return ( Label - ←→ move cursor, Ctrl+←→ jump to start/end, Tab default, Enter save, ESC cancel + ←→ move cursor, Ctrl+←→ jump to start/end, Tab reset to default, Enter save, ESC cancel {`"${display}"`} diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index f5b829b6d..2834f525a 100644 --- a/src/tui/components/__tests__/ItemsEditor.test.ts +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -330,4 +330,45 @@ describe('ItemsEditor', () => { 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/widgets/shared/__tests__/raw-or-labeled.test.ts b/src/widgets/shared/__tests__/raw-or-labeled.test.ts index 212710592..d47e99109 100644 --- a/src/widgets/shared/__tests__/raw-or-labeled.test.ts +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -6,6 +6,7 @@ import { import type { WidgetItem } from '../../../types/Widget'; import { + clearLabel, formatRawOrLabeledValue, getLabel, getLabelModifierText, @@ -36,19 +37,24 @@ describe('formatRawOrLabeledValue', () => { describe('setLabel', () => { it('stores an override that differs from the default', () => { - const item = setLabel(makeItem(), 'Model: ', 'M '); + 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(), 'Model: ', '').metadata).toEqual({ label: '' }); + expect(setLabel(makeItem(), '').metadata).toEqual({ label: '' }); }); - it('drops the override when it matches the default, preserving other metadata', () => { - const item = makeItem({ metadata: { label: 'M ', hide: 'zero' } }); - expect(setLabel(item, 'Model: ', 'Model: ').metadata).toEqual({ hide: 'zero' }); - expect(setLabel(makeItem({ metadata: { label: 'M ' } }), 'Model: ', 'Model: ').metadata).toBeUndefined(); + 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(); }); }); diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 41d766ca4..41ef94823 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -25,13 +25,10 @@ export function clearLabel(item: WidgetItem): WidgetItem { return removeMetadataKeys(item, [LABEL_METADATA_KEY]); } -// An override matching the default is dropped so untouched items stay minimal. -// An empty override is kept: it means "no label" rather than "default". -export function setLabel(item: WidgetItem, defaultLabel: string, value: string): WidgetItem { - if (value === defaultLabel) { - return clearLabel(item); - } - +// 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: { diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts index abbb83d7b..fa4719a4a 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -85,8 +85,6 @@ export function useTextCursor(initialText: string) { return { text: state.text, display: renderTextWithCursor(state), - // Replaces the text and moves the cursor to its end - setText: (text: string) => { setState({ text, cursor: text.length }); }, // Returns whether the key was consumed, so callers can fall through // to their own bindings handleInput: (input: string, key: Key): boolean => { From cea51fb5f21a37965c08ff55c9965eeb441787aa Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 11:20:42 -0400 Subject: [PATCH 13/15] chore(tui): drop a comment that restates getBoundaries Co-Authored-By: Claude Opus 5.5 --- src/widgets/shared/text-cursor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts index fa4719a4a..2d42dd64c 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -20,7 +20,6 @@ export function getGraphemes(str: string): string[] { return Array.from(str); } -// String offsets of every grapheme boundary, from 0 through text.length function getBoundaries(text: string): number[] { const boundaries = [0]; for (const grapheme of getGraphemes(text)) { From c1a984d4e5513effde635ff4f42376e191eed535 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Tue, 6 Oct 2026 11:28:11 -0400 Subject: [PATCH 14/15] test(widgets): cover the reset timers' loading label in bar modes The loading placeholder takes the bar-mode label, so it reads "Reset [Loading]" rather than "Reset: [Loading]" in progress mode. Co-Authored-By: Claude Opus 5.5 --- src/widgets/__tests__/BlockResetTimer.test.ts | 1 + src/widgets/__tests__/WeeklyResetTimer.test.ts | 1 + 2 files changed, 2 insertions(+) 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__/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', () => { From dc2add89b91772819992f57dbceabc11a4530baa Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Tue, 6 Oct 2026 12:11:55 -0400 Subject: [PATCH 15/15] fix(widgets): preserve latest text edits and existing labels --- src/tui/components/ItemsEditor.tsx | 4 +- src/tui/components/LabelEditor.tsx | 4 +- .../components/__tests__/ItemsEditor.test.ts | 56 +++++++++++++++++-- src/widgets/CustomText.tsx | 4 +- src/widgets/shared/text-cursor.ts | 18 ++++-- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 175e0e301..fb83d2eb9 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -176,7 +176,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (currentWidget) { const newWidgets = [...widgets]; // Other metadata carries over, but a label names the old widget's value - newWidgets[selectedIndex] = { ...clearLabel(currentWidget), type: selectedType }; + newWidgets[selectedIndex] = currentWidget.type === selectedType + ? currentWidget + : { ...clearLabel(currentWidget), type: selectedType }; onUpdate(newWidgets); } } else { diff --git a/src/tui/components/LabelEditor.tsx b/src/tui/components/LabelEditor.tsx index a00be027b..2af68d89d 100644 --- a/src/tui/components/LabelEditor.tsx +++ b/src/tui/components/LabelEditor.tsx @@ -21,11 +21,11 @@ export interface LabelEditorProps { } export const LabelEditor: React.FC = ({ widget, defaultLabel, onComplete, onCancel }) => { - const { text, display, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); + const { getText, display, handleInput } = useTextCursor(getLabel(widget, defaultLabel)); useInput((input, key) => { if (key.return) { - onComplete(setLabel(widget, text)); + onComplete(setLabel(widget, getText())); } else if (key.escape) { onCancel(); } else if (key.tab) { diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts index 2834f525a..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 { @@ -217,7 +218,10 @@ describe('ItemsEditor', () => { } }); - it('drops the label when the widget type changes', async () => { + 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(); @@ -240,7 +244,7 @@ describe('ItemsEditor', () => { stdin.write('\x1b[C'); await flushInk(); - for (const char of 'session cost') { + for (const char of query) { stdin.write(char); await flushInk(); } @@ -248,8 +252,12 @@ describe('ItemsEditor', () => { stdin.write('\r'); await flushInk(); const output = stripAnsi(stdout.getOutput()); - expect(output).toContain('1. Session Cost'); - expect(output).not.toContain('(label:'); + expect(output).toContain(`1. ${name}`); + if (keepsLabel) { + expect(output).toContain('(label: "M ")'); + } else { + expect(output).not.toContain('(label:'); + } } finally { instance.unmount(); instance.cleanup(); @@ -299,6 +307,46 @@ describe('ItemsEditor', () => { } }); + 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(); diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index ef3b2afc9..9af974825 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -57,11 +57,11 @@ export class CustomTextWidget implements Widget { } const CustomTextEditor: React.FC = ({ widget, onComplete, onCancel }) => { - const { text, display, handleInput } = useTextCursor(widget.customText ?? ''); + 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 { diff --git a/src/widgets/shared/text-cursor.ts b/src/widgets/shared/text-cursor.ts index 2d42dd64c..acf1e0209 100644 --- a/src/widgets/shared/text-cursor.ts +++ b/src/widgets/shared/text-cursor.ts @@ -1,5 +1,8 @@ import type { Key } from 'ink'; -import { useState } from 'react'; +import { + useRef, + useState +} from 'react'; import { shouldInsertInput } from '../../utils/input-guards'; @@ -80,20 +83,23 @@ export function renderTextWithCursor({ text, cursor }: TextCursorState): string 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 => { - // Whether a key is consumed never depends on the text, so the - // render-time snapshot can answer it. The edit itself must apply - // to the latest state, or keys arriving before a re-render drop. - if (applyTextCursorInput(state, input, key) === null) { + const nextState = applyTextCursorInput(latestState.current, input, key); + if (nextState === null) { return false; } - setState(prev => applyTextCursorInput(prev, input, key) ?? prev); + // Publish edits immediately so another key or Save sees them + // even before React renders the updated text. + latestState.current = nextState; + setState(nextState); return true; } };