diff --git a/docs/USAGE.md b/docs/USAGE.md index e6b3bb05d..2a58d0e80 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -113,6 +113,17 @@ Configure global formatting preferences that apply to all widgets: - Press **(s)** to edit separator - Manual separators look past widgets that render empty, so the intended separator remains between visible neighbors without duplicating an earlier visible boundary. Inherited separator colors come from the actual preceding visible widget. +
+Per-widget width overhead (audit) + +- **Default padding is empty** (`defaultPadding` unset), so widgets add no padding unless you configure it. A non-empty padding of N characters adds up to 2N columns per widget (one per enabled side). +- **Manual `|` separators render as ` | `** (3 columns); in Powerline mode separator widgets are ignored and replaced by the powerline separators. +- **Labels are the dominant fixed cost** of labeled widgets. Defaults: `Model: ` (7), `Ctx: ` (5), `Cost: ` (6), token widgets `In: `/`Out: `/`Total: `/`Cached: ` (4–8), cache widgets `Cache Read: `/`Cache Write: `/`Cache Hit: ` (10–12), timers `Block: `/`Reset: `/`Cache: ` (7) and `Weekly Reset: ` (13), usage widgets `Session: `/`Weekly: `/`Weekly Sonnet: `/`Weekly Opus: `/`Weekly Fable: ` (8–15). +- **Compact Labels** (above) trims the presets it covers (`Model:` −4, `Context:` −4, `Cost:` −6, label gone — the value's own `$` remains); Minimalist Mode strips labels entirely. +- **Unbounded-content widgets** (paths, names, URLs) can overflow a narrow terminal: Git Branch, Git Root Dir, Current Working Dir, and Session Name support a per-widget max-width cap — select the widget in the line editor and press **(w)idth**. The line renderer truncates the whole line with an ellipsis regardless. + +
+
Global Formatting Options @@ -122,6 +133,10 @@ Configure global formatting preferences that apply to all widgets: - Press **(o)** to toggle - **Minimalist Mode** - Force widgets into raw-value rendering globally for a cleaner, label-free status line - Press **(m)** to toggle +- **Compact Labels** - Use short label presets on labeled widgets: `Model:` → `M:`, `Context:` → `Ctx:`, `Cost:` → `$` + - Press **(j)** to toggle; off by default, so existing configs render exactly as before + - Per-widget override: select a labeled widget in the line editor and press **(j) compact label** to force it on/off regardless of the global setting + - Labels without a preset (e.g. `In:`, `Cached:`) keep their default form; presets live in `COMPACT_LABELS` in `src/widgets/shared/raw-or-labeled.ts` - **Number Formatting** - Choose precise, compact, or whole-number output independently for token, speed, percent, memory, and cost values - Press **(n)** to configure each number type; a global choice overrides per-widget formatting for that type - **Override Foreground Color** - Force all widgets to use the same text color, or a whole-line **gradient** (see below) @@ -251,6 +266,7 @@ Common controls in the line editor: - `Space` cycle a manual separator character - `r` toggle raw value (supported widgets) - `.` cycle precise/compact/whole number formatting (supported widgets) +- `j` toggle the compact label preset for labeled widgets (Model → `M:`, Context → `Ctx:`, Cost → `$`) - `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) - `Esc` go back diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index 785576924..6fda4b19e 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -69,6 +69,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors); const [globalBold, setGlobalBold] = useState(settings.globalBold); const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode); + const [compactLabels, setCompactLabels] = useState(settings.compactLabels); const [numberFormatMode, setNumberFormatMode] = useState(false); const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0); const [gradientMode, setGradientMode] = useState(false); @@ -259,6 +260,15 @@ export const GlobalOverridesMenu: React.FC = ({ settin minimalistMode: newMinimalistMode }; onUpdate(updatedSettings); + } else if (input === 'j' || input === 'J') { + // Toggle compact label presets for labeled widgets + const newCompactLabels = !compactLabels; + setCompactLabels(newCompactLabels); + const updatedSettings = { + ...settings, + compactLabels: newCompactLabels + }; + onUpdate(updatedSettings); } else if (input === 'n' || input === 'N') { setNumberFormatMode(true); setNumberFormatKindIndex(0); @@ -451,6 +461,12 @@ export const GlobalOverridesMenu: React.FC = ({ settin - Press (m) to toggle + + Compact Labels: + {compactLabels ? '✓ Enabled' : '✗ Disabled'} + - Press (j) to toggle + + Number Formatting: {settings.numberFormat ? 'customized' : '(defaults)'} @@ -558,6 +574,9 @@ export const GlobalOverridesMenu: React.FC = ({ settin • Minimalist Mode: Strips decorative prefixes and labels from widgets + + • Compact Labels: Uses short label presets (M:, Ctx:, $) on labeled widgets + • Override colors: All widgets will use these colors instead of their configured colors diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 470ee1486..4b19c8dcb 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -21,6 +21,7 @@ import { import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, + getCompactLabelCapableTypes, getMatchSegments, getWidget, getWidgetCatalog, @@ -31,6 +32,10 @@ import { getHideKeybind, getHideModifierText } from '../../widgets/shared/hideable'; +import { + getCompactLabelKeybind, + getCompactLabelModifierText +} from '../../widgets/shared/raw-or-labeled'; import { ConfirmDialog } from './ConfirmDialog'; import { HideStatesEditor } from './HideStatesEditor'; @@ -132,6 +137,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB keybinds.push(getHideKeybind()); } + // Labeled widgets whose label has a compact preset share a single + // compact-label toggle here, mirroring the (h)ide… keybind. + if (getCompactLabelCapableTypes().has(widget.type)) { + keybinds.push(getCompactLabelKeybind()); + } + return keybinds; }; @@ -259,7 +270,8 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + settings }); }); @@ -582,6 +594,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? getNumberFormatModifierText(widget) : undefined; const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined; + const compactModifierText = widgetImpl && getCompactLabelCapableTypes().has(widget.type) + ? getCompactLabelModifierText(widget, settings) + : undefined; return ( @@ -611,6 +626,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {hideModifierText} )} + {compactModifierText && ( + + {' '} + {compactModifierText} + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} diff --git a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts index 36e4b344b..e4f49a8bd 100644 --- a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts +++ b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts @@ -186,6 +186,45 @@ describe('GlobalOverridesMenu', () => { } }); + it('toggles compact labels on when (j) is pressed', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onBack = vi.fn(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: { ...DEFAULT_SETTINGS, compactLabels: false }, + onUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stdout.getOutput()).toContain('Compact Labels:'); + stdin.write('j'); + await flushInk(); + + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ compactLabels: true })); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('toggles minimalist mode off when (m) is pressed while enabled', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 2c7f52b2e..377839baa 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -1,3 +1,4 @@ +import type { Settings } from '../../../types/Settings'; import type { CustomKeybind, Widget, @@ -15,6 +16,10 @@ import { type WidgetCatalogEntry } from '../../../utils/widgets'; import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable'; +import { + TOGGLE_COMPACT_LABEL_ACTION, + toggleCompactLabel +} from '../../../widgets/shared/raw-or-labeled'; export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -354,6 +359,7 @@ export interface HandleNormalInputModeArgs { getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; getUniqueBackgroundColor?: (insertIndex: number) => string | undefined; + settings?: Settings; } export function handleNormalInputMode({ @@ -371,7 +377,8 @@ export function handleNormalInputMode({ openWidgetPicker, getCustomKeybindsForWidget, setCustomEditorWidget, - getUniqueBackgroundColor + getUniqueBackgroundColor, + settings }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { setSelectedIndex(selectedIndex - 1 < 0 ? widgets.length - 1 : selectedIndex - 1); @@ -501,6 +508,14 @@ export function handleNormalInputMode({ const newWidgets = [...widgets]; newWidgets[selectedIndex] = cycleNumberStyle(currentWidget); onUpdate(newWidgets); + } else if (matchedKeybind.action === TOGGLE_COMPACT_LABEL_ACTION) { + // The compact-label toggle is shared by every labeled + // widget, so like the precision cycle it is applied here. + // The settings let the cycle start from the effective + // (possibly globally inherited) state. + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = toggleCompactLabel(currentWidget, settings); + onUpdate(newWidgets); } else if (widgetImpl.handleEditorAction) { const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); if (updatedWidget) { diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 123ac3b88..fbdc216b6 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -74,6 +74,11 @@ export const SettingsSchema = z.object({ overrideBackgroundColor: z.string().optional(), overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), + // Compact label presets (Model: -> M:, Context: -> Ctx:, Cost: -> $) for + // labeled widgets. Additive v4 key with an off default, so configs written + // before it render identically without a version bump; per-widget + // metadata.compactLabel overrides the global setting either way. + compactLabels: z.boolean().default(false), numberFormat: GlobalNumberFormatSchema.optional(), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), // How long a "no TTY" result is reused for the same session, in seconds. diff --git a/src/utils/__tests__/renderer-compact-labels.test.ts b/src/utils/__tests__/renderer-compact-labels.test.ts new file mode 100644 index 000000000..a4baabb8b --- /dev/null +++ b/src/utils/__tests__/renderer-compact-labels.test.ts @@ -0,0 +1,107 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + SettingsSchema, + SettingsSchema_v1, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { toggleCompactLabel } from '../../widgets/shared/raw-or-labeled'; +import { stripSgrCodes } from '../ansi'; +import { migrateConfig } from '../migrations'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + colorLevel: 0, + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +const CONTEXT: RenderContext = { isPreview: false, terminalWidth: 200, data: { model: 'Opus 4.6', cost: { total_cost_usd: 2.456 } } }; + +function renderLine(widgets: WidgetItem[], settings: Settings): string { + const preRenderedLines = preRenderAllWidgets([widgets], settings, CONTEXT); + const maxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + return stripSgrCodes(renderStatusLine(widgets, settings, CONTEXT, preRenderedLines[0] ?? [], maxWidths)); +} + +const TRIO: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost' } +]; + +describe('compact labels rendering', () => { + it('keeps default labels when the setting is absent (old configs)', () => { + expect(DEFAULT_SETTINGS.compactLabels).toBe(false); + expect(renderLine(TRIO, createSettings())).toBe('Model: Opus 4.6 | Cost: $2.46'); + }); + + it('shortens mapped labels when compactLabels is on', () => { + expect(renderLine(TRIO, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | $2.46'); + }); + + it('lets a per-widget opt-out beat the global setting', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost', metadata: { compactLabel: 'false' } } + ]; + expect(renderLine(widgets, createSettings({ compactLabels: true }))).toBe('M: Opus 4.6 | Cost: $2.46'); + }); + + it('lets a per-widget opt-in work without the global setting', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'model', metadata: { compactLabel: 'true' } }, + { id: '2', type: 'separator', character: '|' }, + { id: '3', type: 'session-cost' } + ]; + expect(renderLine(widgets, createSettings())).toBe('M: Opus 4.6 | Cost: $2.46'); + }); + + it('the per-widget (j) toggle beats the global setting at render time', () => { + const settings = createSettings({ compactLabels: true }); + // Simulate pressing (j) once on the model widget while the global + // flag is on: the cycle writes an explicit 'false' and the label + // renders in its default form. + const toggled = toggleCompactLabel({ id: '1', type: 'model' }, settings); + expect(toggled.metadata).toEqual({ compactLabel: 'false' }); + // Model is forced back to its default label while session-cost stays + // compact through the global flag. + expect(renderLine([toggled, { id: '2', type: 'separator', character: '|' }, { id: '3', type: 'session-cost' }], settings)) + .toBe('Model: Opus 4.6 | $2.46'); + }); + + it('parses legacy settings without the new key to the off default', () => { + const legacy = SettingsSchema.parse({ version: 4, lines: [[{ id: '1', type: 'model' }]] }); + expect(legacy.compactLabels).toBe(false); + }); + + it('migrates a v1 config and keeps compact labels off', () => { + const v1 = { lines: [[{ id: '1', type: 'model' }]], globalBold: false }; + const migrated = migrateConfig(v1, 4) as Record; + const parsed = SettingsSchema.parse(migrated); + expect(parsed.version).toBe(4); + expect(parsed.compactLabels).toBe(false); + }); + + it('keeps legacy v1 schema unaware of the new key', () => { + expect('compactLabels' in SettingsSchema_v1.shape).toBe(false); + }); +}); diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 84b286dbe..8043a8add 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -9,9 +9,11 @@ import { type Settings } from '../../types/Settings'; import { getHideKeybind } from '../../widgets/shared/hideable'; +import { getCompactLabelKeybind } from '../../widgets/shared/raw-or-labeled'; import { filterWidgetCatalog, getAllWidgetTypes, + getCompactLabelCapableTypes, getMatchSegments, getWidget, getWidgetCatalog, @@ -197,6 +199,34 @@ describe('hideable state keybind reservation', () => { }); }); +describe('compact label keybind reservation', () => { + it('widgets do not bind the shared compact-label key', () => { + const reservedKey = getCompactLabelKeybind().key; + const settings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + + for (const type of getAllWidgetTypes(settings).filter( + type => type !== 'separator' && type !== 'flex-separator' + )) { + const widget = getWidget(type); + const keys = (widget?.getCustomKeybinds?.() ?? []).map(keybind => keybind.key); + expect(`${type} binds ${keys.includes(reservedKey) ? reservedKey : 'nothing reserved'}`) + .toBe(`${type} binds nothing reserved`); + } + }); + + it('probe marks labeled widgets capable and unlabeled ones not', () => { + const capable = getCompactLabelCapableTypes(); + expect(capable.has('model')).toBe(true); + expect(capable.has('session-cost')).toBe(true); + expect(capable.has('context-bar')).toBe(true); + expect(capable.has('git-branch')).toBe(false); + expect(capable.has('tokens-input')).toBe(false); + }); +}); + describe('widget catalog filtering', () => { const catalog = getWidgetCatalog({ ...DEFAULT_SETTINGS, diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index a3ca51ec3..1f9421d47 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -16,6 +16,7 @@ import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE, isHidden } from '../widgets/shared/hideable'; +import { withGlobalCompactLabels } from '../widgets/shared/raw-or-labeled'; import { applyLineGradient, @@ -896,7 +897,9 @@ export function preRenderAllWidgets( continue; } - const effectiveWidget = context.minimalist ? { ...widget, rawValue: true } : widget; + const effectiveWidget = context.minimalist + ? { ...widget, rawValue: true } + : withGlobalCompactLabels(widget, settings); const widgetText = widgetImpl.render(effectiveWidget, context, settings) ?? ''; // Store the rendered content without padding (padding is applied later) diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 22075f052..44fe7e37f 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -1,10 +1,13 @@ import type { Settings } from '../types/Settings'; +import { DEFAULT_SETTINGS } from '../types/Settings'; import type { Widget, WidgetItem, WidgetItemType } from '../types/Widget'; +import { startsWithCompactLabel } from '../widgets/shared/raw-or-labeled'; +import { getVisibleText } from './ansi'; import { filterFuzzySearchRecords, type FuzzySearchRecord @@ -141,3 +144,35 @@ export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(resolved) || layoutWidgetTypes.has(resolved); } + +// Widget types whose preview output opens with a label that has a compact +// preset; these get the shared compact-label editor keybind. The probe runs +// once and is cached — preview renders are static and cheap, but the scan +// still touches every registered widget. ponytail: preview-probe heuristic; +// if a widget ever stops emitting its label first in preview output it drops +// out of the keybind, and the fix is an explicit capability flag per widget. +let compactLabelCapableTypes: Set | null = null; + +export function getCompactLabelCapableTypes(): Set { + if (compactLabelCapableTypes === null) { + const capable = new Set(); + for (const entry of WIDGET_MANIFEST) { + const widget = widgetRegistry.get(entry.type); + if (!widget) { + continue; + } + try { + const probeItem: WidgetItem = { id: 'compact-label-probe', type: entry.type }; + const output = widget.render(probeItem, { isPreview: true }, DEFAULT_SETTINGS); + if (output && startsWithCompactLabel(getVisibleText(output))) { + capable.add(entry.type); + } + } catch { + // A widget that cannot render without live data is simply not + // compact-label capable. + } + } + compactLabelCapableTypes = capable; + } + return compactLabelCapableTypes; +} diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 16e42fce1..b3fe0ae0c 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -18,6 +18,7 @@ import { import { formatTokens } from '../utils/renderer'; import { makeUsageProgressBar } from '../utils/usage'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { makeSliderBar } from './shared/usage-display'; type DisplayMode = 'progress' | 'progress-short' | 'slider' | 'slider-only'; @@ -93,11 +94,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, 'Context: ', sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`; + return formatRawOrLabeledValue(item, 'Context: ', previewDisplay); } const contextWindowMetrics = getContextWindowMetrics(context.data); @@ -127,13 +128,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, 'Context: ', sliderDisplay); } const barWidth = displayMode === 'progress' ? 32 : 16; const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; - return item.rawValue ? display : `Context: ${display}`; + return formatRawOrLabeledValue(item, 'Context: ', display); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index b5a6fba09..88eadbbd8 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -12,6 +12,13 @@ import type { } from '../types/Widget'; import { lazyEditor } from './shared/lazy-editor'; +import { + MAX_WIDTH_ACTION, + applyMaxWidth, + getMaxWidthKeybind, + getMaxWidthModifier, + renderMaxWidthEditor +} from './shared/max-width'; import { SYMBOL_OVERRIDE_ACTION, formatSymbolPrefix, @@ -40,6 +47,11 @@ export class CurrentWorkingDirWidget implements Widget { modifiers.push(`segments: ${segments}`); } + const maxWidthText = getMaxWidthModifier(item); + if (maxWidthText) { + modifiers.push(maxWidthText); + } + return { displayText: this.getDisplayName(), modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined @@ -167,7 +179,7 @@ export class CurrentWorkingDirWidget implements Widget { } } - return item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`; + return applyMaxWidth(item.rawValue ? `${symbolPrefix}${displayPath}` : `${symbolPrefix}cwd: ${displayPath}`, item.maxWidth); } getCustomKeybinds(): CustomKeybind[] { @@ -175,7 +187,8 @@ export class CurrentWorkingDirWidget implements Widget { { key: 'h', label: '(h)ome ~', action: 'toggle-abbreviate-home' }, { key: 's', label: '(s)egments', action: 'edit-segments' }, { key: 'f', label: '(f)ish style', action: 'toggle-fish-style' }, - getSymbolKeybind() + getSymbolKeybind(), + getMaxWidthKeybind() ]; } @@ -183,6 +196,9 @@ export class CurrentWorkingDirWidget implements Widget { if (props.action === SYMBOL_OVERRIDE_ACTION) { return renderSymbolOverrideEditor(props, ''); } + if (props.action === MAX_WIDTH_ACTION) { + return renderMaxWidthEditor(props); + } return ; } diff --git a/src/widgets/Model.ts b/src/widgets/Model.ts index 7d6db0584..b1cb12324 100644 --- a/src/widgets/Model.ts +++ b/src/widgets/Model.ts @@ -6,6 +6,8 @@ import type { WidgetItem } from '../types/Widget'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + export class ModelWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Displays the Claude model name (e.g., Claude 3.5 Sonnet)'; } @@ -17,7 +19,7 @@ export class ModelWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { - return item.rawValue ? 'Claude' : 'Model: Claude'; + return formatRawOrLabeledValue(item, 'Model: ', 'Claude'); } const model = context.data?.model; @@ -27,7 +29,7 @@ export class ModelWidget implements Widget { if (modelDisplayName) { const shortName = modelDisplayName.replace(/\s*\(.*\)$/, ''); - return item.rawValue ? shortName : `Model: ${shortName}`; + return formatRawOrLabeledValue(item, 'Model: ', shortName); } return null; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 07e336f00..b1002bfa2 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -12,6 +12,7 @@ import { } from '../utils/number-format'; import { isHidden } from './shared/hideable'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when cost is $0.00' }; @@ -31,8 +32,7 @@ export class SessionCostWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - const value = formatCost(2.45, format); - return item.rawValue ? value : `Cost: ${value}`; + return formatRawOrLabeledValue(item, 'Cost: ', formatCost(2.45, format)); } const totalCost = context.data?.cost?.total_cost_usd; @@ -48,7 +48,7 @@ export class SessionCostWidget implements Widget { } const formattedCost = formatCost(totalCost, format); - return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; + return formatRawOrLabeledValue(item, 'Cost: ', formattedCost); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionName.ts b/src/widgets/SessionName.ts index f3bfb7e6d..17875ccde 100644 --- a/src/widgets/SessionName.ts +++ b/src/widgets/SessionName.ts @@ -1,19 +1,33 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { getTranscriptSessionName } from '../utils/jsonl-session'; +import { + MAX_WIDTH_ACTION, + applyMaxWidth, + getMaxWidthKeybind, + getMaxWidthModifier, + renderMaxWidthEditor +} from './shared/max-width'; + 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'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + const maxWidthText = getMaxWidthModifier(item); + return { + displayText: this.getDisplayName(), + modifierText: maxWidthText ?? undefined + }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -28,7 +42,18 @@ export class SessionNameWidget implements Widget { return null; } - return item.rawValue ? sessionName : `Session: ${sessionName}`; + return applyMaxWidth(item.rawValue ? sessionName : `Session: ${sessionName}`, item.maxWidth); + } + + getCustomKeybinds(): CustomKeybind[] { + return [getMaxWidthKeybind()]; + } + + renderEditor(props: WidgetEditorProps) { + if (props.action === MAX_WIDTH_ACTION) { + return renderMaxWidthEditor(props); + } + return null; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/ContextBar.test.ts b/src/widgets/__tests__/ContextBar.test.ts index f8d09ad4a..3f117a223 100644 --- a/src/widgets/__tests__/ContextBar.test.ts +++ b/src/widgets/__tests__/ContextBar.test.ts @@ -44,6 +44,27 @@ describe('ContextBarWidget', () => { expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:15.0:16] 30k/200k (15%)'); }); + it('shortens the Context label to Ctx when the compact preset is on', () => { + const context: RenderContext = { + data: { + context_window: { + context_window_size: 200000, + current_usage: { + input_tokens: 20000, + output_tokens: 10000, + cache_creation_input_tokens: 5000, + cache_read_input_tokens: 5000 + } + } + } + }; + const widget = new ContextBarWidget(); + const item = { id: 'ctx', type: 'context-bar', metadata: { compactLabel: 'true' } }; + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Ctx: [bar:15.0:16] 30k/200k (15%)'); + expect(widget.render({ ...item, rawValue: true }, context, DEFAULT_SETTINGS)).toBe('[bar:15.0:16] 30k/200k (15%)'); + }); + it('falls back to token metrics and model context size', () => { const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } }, diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts index cb5acbd33..27ce7283b 100644 --- a/src/widgets/__tests__/CurrentWorkingDir.test.ts +++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts @@ -46,6 +46,7 @@ describe('CurrentWorkingDirWidget', () => { terminalWidthCacheTtlSeconds: 5, customCommandCacheTtlSeconds: 5, minimalistMode: false, + compactLabels: false, powerline: { enabled: false, separators: [], diff --git a/src/widgets/__tests__/CustomCommand.test.ts b/src/widgets/__tests__/CustomCommand.test.ts index 35c9ed91b..841fac97e 100644 --- a/src/widgets/__tests__/CustomCommand.test.ts +++ b/src/widgets/__tests__/CustomCommand.test.ts @@ -77,6 +77,7 @@ describe('CustomCommandWidget', () => { terminalWidthCacheTtlSeconds: 5, customCommandCacheTtlSeconds: 0, minimalistMode: false, + compactLabels: false, powerline: { enabled: false, separators: [], diff --git a/src/widgets/__tests__/Model.test.ts b/src/widgets/__tests__/Model.test.ts index 4ca4d4d44..daef0a1e6 100644 --- a/src/widgets/__tests__/Model.test.ts +++ b/src/widgets/__tests__/Model.test.ts @@ -45,6 +45,13 @@ describe('ModelWidget', () => { expect(new ModelWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBe('Model: Opus 4.6'); }); + it('shortens the label to the compact preset when enabled', () => { + const ctx = makeContext({ data: { model: { id: 'claude-opus-4-6[1m]', display_name: 'Opus 4.6' } } }); + const compactItem: WidgetItem = { ...ITEM, metadata: { compactLabel: 'true' } }; + expect(new ModelWidget().render(compactItem, ctx, DEFAULT_SETTINGS)).toBe('M: Opus 4.6'); + expect(new ModelWidget().render({ ...compactItem, rawValue: true }, ctx, DEFAULT_SETTINGS)).toBe('Opus 4.6'); + }); + it('returns null when model is absent', () => { const ctx = makeContext({ data: {} }); expect(new ModelWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull(); diff --git a/src/widgets/__tests__/SessionCost.test.ts b/src/widgets/__tests__/SessionCost.test.ts index f5bfd3866..dd5be1744 100644 --- a/src/widgets/__tests__/SessionCost.test.ts +++ b/src/widgets/__tests__/SessionCost.test.ts @@ -36,6 +36,12 @@ describe('SessionCostWidget', () => { }, { isPreview: true })).toBe('Cost: $2'); }); + it('compact labels render the $ glyph prefix', () => { + const item = { id: 'session-cost', type: 'session-cost', metadata: { compactLabel: 'true' } }; + expect(render(item, { data: { cost: { total_cost_usd: 2.456 } } })).toBe('$2.46'); + expect(render(item, { isPreview: true })).toBe('$2.45'); + }); + it('declares the zero hideable state', () => { expect(new SessionCostWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); }); 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..f1acf2466 --- /dev/null +++ b/src/widgets/shared/__tests__/raw-or-labeled.test.ts @@ -0,0 +1,117 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { + formatRawOrLabeledValue, + getCompactLabelKeybind, + getCompactLabelModifierText, + startsWithCompactLabel, + toggleCompactLabel, + withGlobalCompactLabels +} from '../raw-or-labeled'; + +const ITEM: WidgetItem = { id: '1', type: 'model' }; + +describe('formatRawOrLabeledValue', () => { + it('keeps the default label when compact labels are off', () => { + expect(formatRawOrLabeledValue(ITEM, 'Model: ', 'Opus')).toBe('Model: Opus'); + }); + + it('uses the compact preset when the item opts in', () => { + expect(formatRawOrLabeledValue(toggleCompactLabel(ITEM), 'Model: ', 'Opus')).toBe('M: Opus'); + }); + + it('maps Context to Ctx and Cost to the $ glyph', () => { + const compact = toggleCompactLabel(ITEM); + expect(formatRawOrLabeledValue(compact, 'Context: ', '[====] 25%')).toBe('Ctx: [====] 25%'); + expect(formatRawOrLabeledValue(compact, 'Cost: ', '$2.45')).toBe('$2.45'); + }); + + it('keeps labels without a preset unchanged in compact mode', () => { + expect(formatRawOrLabeledValue(toggleCompactLabel(ITEM), 'In: ', '15.2k')).toBe('In: 15.2k'); + }); + + it('rawValue wins over compact labels', () => { + expect(formatRawOrLabeledValue({ ...ITEM, rawValue: true, metadata: { compactLabel: 'true' } }, 'Model: ', 'Opus')).toBe('Opus'); + }); +}); + +describe('toggleCompactLabel', () => { + it('cycles inherit -> on -> forced off -> inherit without the global setting', () => { + const on = toggleCompactLabel(ITEM); + expect(on.metadata).toEqual({ compactLabel: 'true' }); + + const forcedOff = toggleCompactLabel(on); + expect(forcedOff.metadata).toEqual({ compactLabel: 'false' }); + + const inherit = toggleCompactLabel(forcedOff); + expect(inherit.metadata).toBeUndefined(); + expect(inherit).toEqual(ITEM); + }); + + it('starts from the effective state while the global setting is on', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + // Inherited compact -> first press forces off instead of a no-op. + const forcedOff = toggleCompactLabel(ITEM, settings); + expect(forcedOff.metadata).toEqual({ compactLabel: 'false' }); + + // Second press drops the key and inherits the global setting again. + expect(toggleCompactLabel(forcedOff, settings).metadata).toBeUndefined(); + }); + + it('preserves sibling metadata', () => { + const item: WidgetItem = { id: '1', type: 'model', metadata: { hide: 'no-git' } }; + expect(toggleCompactLabel(item).metadata).toEqual({ hide: 'no-git', compactLabel: 'true' }); + expect(toggleCompactLabel(toggleCompactLabel(toggleCompactLabel(item))).metadata).toEqual({ hide: 'no-git' }); + }); +}); + +describe('withGlobalCompactLabels', () => { + it('is a no-op when the global setting is off', () => { + expect(withGlobalCompactLabels(ITEM, DEFAULT_SETTINGS)).toBe(ITEM); + }); + + it('injects the compact flag when the global setting is on', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + expect(withGlobalCompactLabels(ITEM, settings).metadata).toEqual({ compactLabel: 'true' }); + }); + + it('lets an explicit per-widget choice win over the global setting', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + const optedOut: WidgetItem = { id: '1', type: 'model', metadata: { compactLabel: 'false' } }; + expect(withGlobalCompactLabels(optedOut, settings)).toBe(optedOut); + }); + + it('does not touch raw-value items', () => { + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + const raw: WidgetItem = { ...ITEM, rawValue: true }; + expect(withGlobalCompactLabels(raw, settings)).toBe(raw); + }); +}); + +describe('startsWithCompactLabel', () => { + it('matches labels that have presets and rejects the rest', () => { + expect(startsWithCompactLabel('Model: Opus')).toBe(true); + expect(startsWithCompactLabel('Context: [bar]')).toBe(true); + expect(startsWithCompactLabel('Cost: $1')).toBe(true); + expect(startsWithCompactLabel('In: 15k')).toBe(false); + expect(startsWithCompactLabel('')).toBe(false); + }); +}); + +describe('compact label keybind', () => { + it('uses a key no widget binds and reports the effective state', () => { + expect(getCompactLabelKeybind().key).toBe('j'); + const settings = { ...DEFAULT_SETTINGS, compactLabels: true }; + + expect(getCompactLabelModifierText(ITEM)).toBeUndefined(); + expect(getCompactLabelModifierText(ITEM, settings)).toBe('(compact label: on)'); + expect(getCompactLabelModifierText(toggleCompactLabel(ITEM))).toBe('(compact label)'); + expect(getCompactLabelModifierText(toggleCompactLabel(toggleCompactLabel(ITEM)), settings)).toBe('(compact label: off)'); + }); +}); diff --git a/src/widgets/shared/raw-or-labeled.ts b/src/widgets/shared/raw-or-labeled.ts index 94d4d847c..bdb67e6e2 100644 --- a/src/widgets/shared/raw-or-labeled.ts +++ b/src/widgets/shared/raw-or-labeled.ts @@ -1,5 +1,123 @@ -import type { WidgetItem } from '../../types/Widget'; +import type { Settings } from '../../types/Settings'; +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +// Abbreviated label presets applied when compact labels are enabled. Keys are +// exact default label prefixes as passed to formatRawOrLabeledValue; labels +// without an entry keep their default form. An empty replacement strips the +// label (Cost: $2.46 -> $2.46 — the value's own $ is the glyph). Add entries +// here to cover more widgets — everything routed through the helper picks +// them up automatically. +const COMPACT_LABELS: Record = { + 'Model: ': 'M: ', + 'Context: ': 'Ctx: ', + 'Cost: ': '' +}; + +const COMPACT_LABEL_METADATA_KEY = 'compactLabel'; + +export const TOGGLE_COMPACT_LABEL_ACTION = 'toggle-compact-label'; + +// Whether visible text opens with a label that has a compact preset. Used by +// the widget registry probe that decides which widgets see the compact-label +// editor keybind. +export function startsWithCompactLabel(text: string): boolean { + return Object.keys(COMPACT_LABELS).some(prefix => text.startsWith(prefix)); +} + +const COMPACT_LABEL_KEYBIND: CustomKeybind = { + key: 'j', + label: '(j) compact label', + action: TOGGLE_COMPACT_LABEL_ACTION +}; + +export function isCompactLabelEnabled(item: WidgetItem): boolean { + return item.metadata?.[COMPACT_LABEL_METADATA_KEY] === 'true'; +} export function formatRawOrLabeledValue(item: WidgetItem, labelPrefix: string, value: string): string { - return item.rawValue ? value : `${labelPrefix}${value}`; + if (item.rawValue) { + return value; + } + if (isCompactLabelEnabled(item)) { + const compact = COMPACT_LABELS[labelPrefix]; + if (compact !== undefined) { + return `${compact}${value}`; + } + } + return `${labelPrefix}${value}`; +} + +// Cycle the per-widget compact-label override over the widget's EFFECTIVE +// state, so the toggle stays useful while the global setting is on: compact +// now (saved 'true', or inherited because the global flag is on) -> write an +// explicit 'false'; forced off -> drop the key and inherit the global setting +// again; otherwise -> write 'true'. Dropping the key keeps untouched items' +// metadata minimal. +export function toggleCompactLabel(item: WidgetItem, settings?: Settings): WidgetItem { + const saved = item.metadata?.[COMPACT_LABEL_METADATA_KEY]; + if (saved === 'true' || (saved === undefined && settings?.compactLabels === true)) { + return withCompactLabel(item, 'false'); + } + if (saved === 'false') { + const { [COMPACT_LABEL_METADATA_KEY]: removed, ...restMetadata } = item.metadata ?? {}; + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + return withCompactLabel(item, 'true'); +} + +function withCompactLabel(item: WidgetItem, value: string): WidgetItem { + return { + ...item, + metadata: { + ...item.metadata, + [COMPACT_LABEL_METADATA_KEY]: value + } + }; +} + +export function getCompactLabelKeybind(): CustomKeybind { + return COMPACT_LABEL_KEYBIND; +} + +// Modifier text reflects the effective state, not just saved metadata, so a +// widget that renders compact via the global setting is still marked. +export function getCompactLabelModifierText(item: WidgetItem, settings?: Settings): string | undefined { + const saved = item.metadata?.[COMPACT_LABEL_METADATA_KEY]; + if (saved === 'true') { + return '(compact label)'; + } + if (saved === 'false') { + return '(compact label: off)'; + } + if (saved === undefined && settings?.compactLabels === true) { + return '(compact label: on)'; + } + return undefined; +} + +// Fold the global Compact Labels setting into the item's metadata right before +// rendering. An explicit per-widget choice wins: 'false' blocks the global +// override, 'true' enables it regardless of the setting. Injecting at render +// time keeps the stored settings untouched and works for every widget routed +// through formatRawOrLabeledValue. +export function withGlobalCompactLabels(widget: WidgetItem, settings: Settings): WidgetItem { + if (!settings.compactLabels || widget.rawValue) { + return widget; + } + if (widget.metadata?.[COMPACT_LABEL_METADATA_KEY] !== undefined) { + return widget; + } + return { + ...widget, + metadata: { + ...widget.metadata, + [COMPACT_LABEL_METADATA_KEY]: 'true' + } + }; }