From 468e5fdb8d98015a4300c7b3e0a8678ca113ef93 Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 12 Jun 2026 19:30:12 -0500 Subject: [PATCH 1/2] feat: add per-widget dim styling, whole widget or parens-only Co-Authored-By: Claude --- src/tui/components/ColorMenu.tsx | 16 +- .../color-menu/__tests__/mutations.test.ts | 27 ++- src/tui/components/color-menu/mutations.ts | 27 +++ src/types/Widget.ts | 1 + src/utils/__tests__/renderer-dim.test.ts | 218 ++++++++++++++++++ src/utils/colors.ts | 26 ++- src/utils/renderer.ts | 34 ++- 7 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 src/utils/__tests__/renderer-dim.test.ts diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index d2ae6606..818bc3e5 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -23,6 +23,7 @@ import { ConfirmDialog } from './ConfirmDialog'; import { clearAllWidgetStyling, cycleWidgetColor, + cycleWidgetDim, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -268,6 +269,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } + } else if (input === 'd' || input === 'D') { + if (highlightedItemId && highlightedItemId !== 'back') { + // Cycle dim for the highlighted item: off -> whole -> parens -> off + const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); + if (selectedWidget) { + const newItems = cycleWidgetDim(widgets, selectedWidget.id); + onUpdate(newItems); + } + } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -347,7 +357,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin defaultColor = widgetImpl.getDefaultColor(); } } - const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level); + const styledLabel = applyColors(label, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, level, widget.dim); return { label: styledLabel, value: widget.id @@ -573,7 +583,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, + , (f) to toggle bg/fg, (b)old, (d)im, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} @@ -598,6 +608,8 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin {' '} {colorDisplay} {selectedWidget.bold && chalk.bold(' [BOLD]')} + {selectedWidget.dim === true && chalk.dim(' [DIM]')} + {selectedWidget.dim === 'parens' && chalk.dim(' [DIM ()]')} ) : ( diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index 00a5650f..d4b7e300 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -8,6 +8,7 @@ import type { WidgetItem } from '../../../../types/Widget'; import { clearAllWidgetStyling, cycleWidgetColor, + cycleWidgetDim, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -41,14 +42,31 @@ describe('color-menu mutations', () => { expect(updated[1]?.bold).toBe(false); }); - it('resetWidgetStyling removes color, backgroundColor, and bold from one widget', () => { + it('cycleWidgetDim cycles off, whole widget, parens, then off for the selected widget only', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]; + + const whole = cycleWidgetDim(widgets, '1'); + const parens = cycleWidgetDim(whole, '1'); + const off = cycleWidgetDim(parens, '1'); + + expect(whole[0]?.dim).toBe(true); + expect(parens[0]?.dim).toBe('parens'); + expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); + expect(whole[1]?.dim).toBeUndefined(); + }); + + it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'tokens-input', color: 'red', backgroundColor: 'blue', - bold: true + bold: true, + dim: 'parens' }, { id: '2', type: 'tokens-output', color: 'white', bold: true } ]; @@ -66,9 +84,10 @@ describe('color-menu mutations', () => { type: 'tokens-input', color: 'red', backgroundColor: 'blue', - bold: true + bold: true, + dim: true }, - { id: '2', type: 'tokens-output', color: 'white', bold: true } + { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' } ]; const updated = clearAllWidgetStyling(widgets); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index bfccc785..855b7976 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -37,17 +37,42 @@ export function toggleWidgetBold(widgets: WidgetItem[], widgetId: string): Widge })); } +export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + // Cycle: off -> whole widget -> (...) spans only -> off + if (widget.dim === true) { + return { + ...widget, + dim: 'parens' as const + }; + } + + if (widget.dim === 'parens') { + const { dim, ...restWidget } = widget; + void dim; // Intentionally unused + return restWidget; + } + + return { + ...widget, + dim: true + }; + }); +} + export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { color, backgroundColor, bold, + dim, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused + void dim; // Intentionally unused return restWidget; }); } @@ -58,11 +83,13 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { color, backgroundColor, bold, + dim, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused + void dim; // Intentionally unused return restWidget; }); } diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 6415bcf4..121c2a9b 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -10,6 +10,7 @@ export const WidgetItemSchema = z.object({ color: z.string().optional(), backgroundColor: z.string().optional(), bold: z.boolean().optional(), + dim: z.union([z.boolean(), z.literal('parens')]).optional(), character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), diff --git a/src/utils/__tests__/renderer-dim.test.ts b/src/utils/__tests__/renderer-dim.test.ts new file mode 100644 index 00000000..3973c23a --- /dev/null +++ b/src/utils/__tests__/renderer-dim.test.ts @@ -0,0 +1,218 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + getVisibleText, + getVisibleWidth +} from '../ansi'; +import { + applyColors, + applyParensDim +} from '../colors'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const INTENSITY_RESET = '\x1b[22m'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + flexMode: 'full', + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function renderLine( + widgets: WidgetItem[], + options: { settings?: Partial; terminalWidth?: number } = {} +): string { + const settings = createSettings(options.settings); + const context: RenderContext = { + isPreview: false, + terminalWidth: options.terminalWidth + }; + + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + const preRenderedWidgets = preRenderedLines[0] ?? []; + + return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); +} + +describe('applyColors dim handling', () => { + it('dims the whole text with a single intensity reset', () => { + expect(applyColors('ctx', undefined, undefined, false, 'ansi16', true)).toBe(`${DIM}ctx${INTENSITY_RESET}`); + }); + + it('emits one intensity reset when bold and dim are combined', () => { + expect(applyColors('ctx', undefined, undefined, true, 'ansi16', true)).toBe(`${BOLD}${DIM}ctx${INTENSITY_RESET}`); + }); + + it('dims only parenthesized spans in parens mode', () => { + expect(applyColors('42k (21%)', undefined, undefined, false, 'ansi16', 'parens')).toBe(`42k ${DIM}(21%)${INTENSITY_RESET}`); + }); + + it('re-asserts bold after each parens span when bold is active', () => { + expect(applyColors('42k (21%)', undefined, undefined, true, 'ansi16', 'parens')).toBe(`${BOLD}42k ${DIM}(21%)${INTENSITY_RESET}${BOLD}${INTENSITY_RESET}`); + }); + + it('leaves text without parens untouched', () => { + expect(applyParensDim('plain text')).toBe('plain text'); + }); + + it('dims multiple parens spans independently', () => { + expect(applyParensDim('(a) mid (b)')).toBe(`${DIM}(a)${INTENSITY_RESET} mid ${DIM}(b)${INTENSITY_RESET}`); + }); +}); + +describe('renderer dim styling', () => { + it('dims a whole widget in normal mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'hello', + dim: true + } + ]; + + const line = renderLine(widgets); + expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0); + expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('hello')); + expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('hello')); + }); + + it('dims only the parens span in normal mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + dim: 'parens' + } + ]; + + const line = renderLine(widgets); + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`); + expect(line.indexOf('ctx')).toBeLessThan(line.indexOf(DIM)); + }); + + it('keeps surrounding bold across a parens span', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + bold: true, + dim: 'parens' + } + ]; + + const line = renderLine(widgets); + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}${BOLD}`); + }); + + it('does not change the visible text or width', () => { + const plain: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)' + } + ]; + const dimmed: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + bold: true, + dim: 'parens' + } + ]; + + const plainLine = renderLine(plain); + const dimmedLine = renderLine(dimmed); + expect(getVisibleText(dimmedLine)).toBe(getVisibleText(plainLine)); + expect(getVisibleWidth(dimmedLine)).toBe(getVisibleWidth(plainLine)); + }); + + it('applies dim in powerline mode and resets after the separator', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'head', + color: 'white', + backgroundColor: 'bgBlue', + dim: true + }, + { + id: 'w2', + type: 'custom-text', + customText: 'tail', + color: 'white', + backgroundColor: 'bgGreen' + } + ]; + + const line = renderLine(widgets, { + settings: { + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0); + expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('head')); + expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('\uE0B0')); + expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('tail')); + }); + + it('dims parens spans in powerline mode', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + color: 'white', + backgroundColor: 'bgBlue', + dim: 'parens' + } + ]; + + const line = renderLine(widgets, { + settings: { + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`); + }); +}); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index daf5c704..618b36af 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -128,15 +128,25 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16 } } +// Dim each (...) span within the text. \x1b[22m clears bold along with dim, +// so bold is re-asserted after each span when the surrounding text is bold. +export function applyParensDim(text: string, bold?: boolean): string { + const restoreBold = bold ? '\x1b[1m' : ''; + return text.replace(/\([^()]*\)/g, span => `\x1b[2m${span}\x1b[22m${restoreBold}`); +} + export function applyColors( text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, - colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16' + colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16', + dim?: boolean | 'parens' ): string { - if (!foregroundColor && !backgroundColor && !bold) { - return text; + const styledText = dim === 'parens' ? applyParensDim(text, bold) : text; + + if (!foregroundColor && !backgroundColor && !bold && dim !== true) { + return styledText; } // Use raw ANSI codes for precise reset sequencing. @@ -144,9 +154,15 @@ export function applyColors( let prefix = ''; let suffix = ''; - // Apply bold first so it can be reset independently before color resets. + // Apply bold/dim first so they can be reset independently before color + // resets. A single \x1b[22m clears both attributes. if (bold) { prefix += '\x1b[1m'; + } + if (dim === true) { + prefix += '\x1b[2m'; + } + if (bold || dim === true) { suffix = '\x1b[22m' + suffix; } @@ -178,7 +194,7 @@ export function applyColors( } } - return prefix + text + suffix; + return prefix + styledText + suffix; } // Get raw ANSI codes for a color without the reset codes diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 63a933e4..0d066a9b 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -17,6 +17,7 @@ import { } from './ansi'; import { applyColors, + applyParensDim, bgToFg, getColorAnsiCode, getPowerlineTheme @@ -324,6 +325,7 @@ function renderPowerlineStatusLine( // Apply colors to widget content using raw ANSI codes for powerline mode // This avoids reset codes that interfere with separator rendering const shouldBold = (settings.globalBold) || widget.widget.bold; + const shouldDim = widget.widget.dim === true; // Check if we need a separator after this widget const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget && !widget.widget.merge; @@ -336,6 +338,9 @@ function renderPowerlineStatusLine( if (shouldBold && !isPreserveColors) { widgetContent += '\x1b[1m'; } + if (shouldDim && !isPreserveColors) { + widgetContent += '\x1b[2m'; + } const textGradientStops = !isPreserveColors && powerlineGradientWidth > 1 ? overrideForegroundGradientStops : null; @@ -357,6 +362,8 @@ function renderPowerlineStatusLine( ); widgetContent += gradientResult.text; powerlineGradientColumn = gradientResult.nextColumn; + } else if (widget.widget.dim === 'parens' && !isPreserveColors) { + widgetContent += applyParensDim(widget.content, shouldBold); } else { widgetContent += widget.content; } @@ -367,10 +374,10 @@ function renderPowerlineStatusLine( widgetContent += '\x1b[0m'; } else { widgetContent += '\x1b[49m\x1b[39m'; - // Only reset bold if there's no separator following AND no end cap + // Only reset bold/dim if there's no separator following AND no end cap const isLastWidget = i === widgetElements.length - 1; const hasEndCap = endCaps.length > 0 && endCaps[capLineIndex % endCaps.length]; - if (shouldBold && !needsSeparator && !(isLastWidget && hasEndCap)) { + if ((shouldBold || shouldDim) && !needsSeparator && !(isLastWidget && hasEndCap)) { widgetContent += '\x1b[22m'; } } @@ -461,8 +468,8 @@ function renderPowerlineStatusLine( result += separatorOutput; - // Reset bold after separator if it was set - if (shouldBold) { + // Reset bold/dim after separator if either was set + if (shouldBold || shouldDim) { result += '\x1b[22m'; } } @@ -481,9 +488,10 @@ function renderPowerlineStatusLine( result += endCap; } - // Reset bold after end cap if needed + // Reset bold/dim after end cap if needed const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold; - if (lastWidgetBold) { + const lastWidgetDim = lastWidget?.widget.dim === true; + if (lastWidgetBold || lastWidgetDim) { result += '\x1b[22m'; } } @@ -675,8 +683,8 @@ export function renderStatusLine( preCalculatedMaxWidths ); - // Helper to apply colors with optional background and bold override - const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => { + // Helper to apply colors with optional background, bold, and dim + const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, dim?: boolean | 'parens'): string => { // Override foreground color takes precedence over EVERYTHING, including passed foreground // color — except a gradient: spec, which is not a solid color. The gradient is applied as a // whole-line pass after assembly, so when it will render (color levels above ansi16) we emit @@ -699,7 +707,7 @@ export function renderStatusLine( } const shouldBold = (settings.globalBold) || bold; - return applyColors(text, fgColor, bgColor, shouldBold, colorLevel); + return applyColors(text, fgColor, bgColor, shouldBold, colorLevel, dim); }; const detectedWidth = context.terminalWidth ?? getTerminalWidth(); @@ -744,6 +752,7 @@ export function renderStatusLine( let separatorColor = widget.color ?? 'gray'; let separatorBg = widget.backgroundColor; let separatorBold = widget.bold; + let separatorDim = widget.dim; if (settings.inheritSeparatorColors && i > 0 && !widget.color && !widget.backgroundColor) { // Only inherit if the separator doesn't have explicit colors set @@ -758,10 +767,11 @@ export function renderStatusLine( separatorColor = widgetColor; separatorBg = prevWidget.backgroundColor; separatorBold = prevWidget.bold; + separatorDim = prevWidget.dim; } } - elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold), type: 'separator', widget }); + elements.push({ content: applyColorsWithOverride(formattedSep, separatorColor, separatorBg, separatorBold, separatorDim), type: 'separator', widget }); continue; } @@ -803,7 +813,7 @@ export function renderStatusLine( } else { // Normal widget rendering with colors elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold), + content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), type: widget.type, widget }); @@ -848,7 +858,7 @@ export function renderStatusLine( const widgetImpl = getWidget(prevElem.widget.type); widgetColor = widgetImpl ? widgetImpl.getDefaultColor() : 'white'; } - const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold); + const coloredSep = applyColorsWithOverride(defaultSep, widgetColor, prevElem.widget.backgroundColor, prevElem.widget.bold, prevElem.widget.dim); finalElements.push(coloredSep); } else { finalElements.push(defaultSep); From 799f727651d19e3d20f5ecd6502294da66b3ce6a Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Mon, 15 Jun 2026 14:58:09 -0400 Subject: [PATCH 2/2] fix(tui): scope dim styling in previews Preserve parens dim styling when foreground gradients render in both regular and powerline paths. Emit a combined intensity reset when restoring bold after dim so Ink preserves the dim reset in preview output. Reset whole-widget dim before powerline separators and end caps, and render ColorMenu style indicators as one suffix to avoid badge wrapping. Add regression coverage for gradient dim composition, powerline dim boundaries, Ink preview output, and ColorMenu indicator layout. --- src/tui/components/ColorMenu.tsx | 9 +- .../components/__tests__/ColorMenu.test.tsx | 122 ++++++++++++++++ .../__tests__/StatusLinePreview.test.ts | 134 +++++++++++++++++- src/utils/__tests__/renderer-dim.test.ts | 112 ++++++++++++++- src/utils/colors.ts | 6 +- src/utils/renderer.ts | 23 +-- 6 files changed, 386 insertions(+), 20 deletions(-) create mode 100644 src/tui/components/__tests__/ColorMenu.test.tsx diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 818bc3e5..41a941b4 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -441,6 +441,11 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin colorDisplay = applyColors(displayName, currentColor, undefined, false, level); } } + const styleIndicators = [ + selectedWidget?.bold ? '[BOLD]' : null, + selectedWidget?.dim === true ? '[DIM]' : null, + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null + ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view if (gradientMode) { @@ -607,9 +612,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ): {' '} {colorDisplay} - {selectedWidget.bold && chalk.bold(' [BOLD]')} - {selectedWidget.dim === true && chalk.dim(' [DIM]')} - {selectedWidget.dim === 'parens' && chalk.dim(' [DIM ()]')} + {styleIndicators && ` ${styleIndicators}`} ) : ( diff --git a/src/tui/components/__tests__/ColorMenu.test.tsx b/src/tui/components/__tests__/ColorMenu.test.tsx new file mode 100644 index 00000000..2bd37c01 --- /dev/null +++ b/src/tui/components/__tests__/ColorMenu.test.tsx @@ -0,0 +1,122 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { ColorMenu } from '../ColorMenu'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +describe('ColorMenu', () => { + it('keeps bold and dim indicators on the current-style row', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const widgets: WidgetItem[] = [ + { id: '1', type: 'cache-hit-rate' }, + { + id: '2', + type: 'cache-read', + color: 'hex:ABB2BF', + backgroundColor: 'bgBrightBlack', + bold: true, + dim: 'parens' + }, + { id: '3', type: 'cache-write' }, + { id: '4', type: 'tokens-cached' } + ]; + + const instance = render( + React.createElement(ColorMenu, { + widgets, + settings: { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true + } + }, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write('\x1B[B'); + await flushInk(); + + const latestFrame = stdout.getOutput().split('Configure Colors').at(-1) ?? ''; + const currentStyleLine = latestFrame + .split('\n') + .find(line => line.includes('Current foreground')) ?? ''; + + expect(currentStyleLine).toContain('[BOLD] [DIM ()]'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/src/tui/components/__tests__/StatusLinePreview.test.ts b/src/tui/components/__tests__/StatusLinePreview.test.ts index 9cf7af32..c72dfa90 100644 --- a/src/tui/components/__tests__/StatusLinePreview.test.ts +++ b/src/tui/components/__tests__/StatusLinePreview.test.ts @@ -1,12 +1,68 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React from 'react'; import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; import { getVisibleWidth } from '../../../utils/ansi'; import { renderOsc8Link } from '../../../utils/hyperlink'; -import { preparePreviewLineForTerminal } from '../StatusLinePreview'; +import { + StatusLinePreview, + preparePreviewLineForTerminal +} from '../StatusLinePreview'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 160; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} describe('StatusLinePreview helpers', () => { it('strips OSC links and clamps preview lines to the terminal width', () => { @@ -21,4 +77,80 @@ describe('StatusLinePreview helpers', () => { expect(prepared.endsWith('...')).toBe(true); expect(getVisibleWidth(` ${prepared}`)).toBeLessThanOrEqual(40); }); + + it('keeps parens dim scoped in the Ink preview when global bold is active', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const settings: Settings = { + ...DEFAULT_SETTINGS, + colorLevel: 3, + globalBold: true, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom', + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + }; + const lines: WidgetItem[][] = [[ + { + id: 'w1', + type: 'custom-text', + customText: 'Cache Hit: 87.0%', + color: 'hex:282C34', + backgroundColor: 'hex:61AFEF' + }, + { + id: 'w2', + type: 'custom-text', + customText: 'Cache Read: 12k (64.0%)', + color: 'hex:ABB2BF', + backgroundColor: 'hex:3E4452', + dim: 'parens' + }, + { + id: 'w3', + type: 'custom-text', + customText: 'Cache Write: 3k (16.0%)', + color: 'hex:282C34', + backgroundColor: 'hex:98C379' + } + ]]; + + const instance = render( + React.createElement(StatusLinePreview, { + lines, + terminalWidth: 160, + settings + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + const output = stdout.getOutput(); + const dimIndex = output.indexOf('\x1b[2m(64.0%)'); + const resetIndex = output.indexOf('\x1b[22;1m', dimIndex); + const nextWidgetIndex = output.indexOf('Cache Write'); + + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(resetIndex).toBeGreaterThan(dimIndex); + expect(resetIndex).toBeLessThan(nextWidgetIndex); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/utils/__tests__/renderer-dim.test.ts b/src/utils/__tests__/renderer-dim.test.ts index 3973c23a..57293f5a 100644 --- a/src/utils/__tests__/renderer-dim.test.ts +++ b/src/utils/__tests__/renderer-dim.test.ts @@ -27,6 +27,8 @@ import { const DIM = '\x1b[2m'; const BOLD = '\x1b[1m'; const INTENSITY_RESET = '\x1b[22m'; +const INTENSITY_RESET_BOLD = '\x1b[22;1m'; +const TRUECOLOR_CODE = /\x1b\[38;2;\d+;\d+;\d+m/g; function createSettings(overrides: Partial = {}): Settings { return { @@ -71,7 +73,7 @@ describe('applyColors dim handling', () => { }); it('re-asserts bold after each parens span when bold is active', () => { - expect(applyColors('42k (21%)', undefined, undefined, true, 'ansi16', 'parens')).toBe(`${BOLD}42k ${DIM}(21%)${INTENSITY_RESET}${BOLD}${INTENSITY_RESET}`); + expect(applyColors('42k (21%)', undefined, undefined, true, 'ansi16', 'parens')).toBe(`${BOLD}42k ${DIM}(21%)${INTENSITY_RESET_BOLD}${INTENSITY_RESET}`); }); it('leaves text without parens untouched', () => { @@ -81,6 +83,20 @@ describe('applyColors dim handling', () => { it('dims multiple parens spans independently', () => { expect(applyParensDim('(a) mid (b)')).toBe(`${DIM}(a)${INTENSITY_RESET} mid ${DIM}(b)${INTENSITY_RESET}`); }); + + it('preserves parens dim when applying a foreground gradient', () => { + const line = applyColors('ctx (42%)', 'gradient:FF0000-0000FF', undefined, false, 'truecolor', 'parens'); + const dimIndex = line.indexOf(DIM); + const openParenIndex = line.indexOf('('); + const closeParenIndex = line.indexOf(')'); + const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex); + + expect(getVisibleText(line)).toBe('ctx (42%)'); + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(openParenIndex); + expect(resetIndex).toBeGreaterThan(closeParenIndex); + expect(line.match(TRUECOLOR_CODE)).toHaveLength(8); + }); }); describe('renderer dim styling', () => { @@ -127,7 +143,7 @@ describe('renderer dim styling', () => { ]; const line = renderLine(widgets); - expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}${BOLD}`); + expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET_BOLD}`); }); it('does not change the visible text or width', () => { @@ -154,7 +170,7 @@ describe('renderer dim styling', () => { expect(getVisibleWidth(dimmedLine)).toBe(getVisibleWidth(plainLine)); }); - it('applies dim in powerline mode and resets after the separator', () => { + it('applies dim in powerline mode and resets before the separator', () => { const widgets: WidgetItem[] = [ { id: 'w1', @@ -186,10 +202,62 @@ describe('renderer dim styling', () => { expect(line.indexOf(DIM)).toBeGreaterThanOrEqual(0); expect(line.indexOf(DIM)).toBeLessThan(line.indexOf('head')); - expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('\uE0B0')); + expect(line.indexOf(INTENSITY_RESET)).toBeGreaterThan(line.indexOf('head')); + expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('\uE0B0')); expect(line.indexOf(INTENSITY_RESET)).toBeLessThan(line.indexOf('tail')); }); + it('does not leak dim past a middle powerline widget with customized colors', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'head', + color: 'hex:ECEFF4', + backgroundColor: 'hex:5E81AC' + }, + { + id: 'w2', + type: 'custom-text', + customText: 'mid', + color: 'hex:ECEFF4', + backgroundColor: 'hex:A3BE8C', + dim: true + }, + { + id: 'w3', + type: 'custom-text', + customText: 'tail', + color: 'hex:ECEFF4', + backgroundColor: 'hex:BF616A' + } + ]; + + const line = renderLine(widgets, { + settings: { + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom', + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + const dimIndex = line.indexOf(DIM); + const midIndex = line.indexOf('mid'); + const separatorAfterMidIndex = line.indexOf('\uE0B0', midIndex); + const tailIndex = line.indexOf('tail'); + const resetAfterMidIndex = line.indexOf(INTENSITY_RESET, midIndex); + + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(midIndex); + expect(resetAfterMidIndex).toBeGreaterThan(midIndex); + expect(resetAfterMidIndex).toBeLessThan(separatorAfterMidIndex); + expect(resetAfterMidIndex).toBeLessThan(tailIndex); + }); + it('dims parens spans in powerline mode', () => { const widgets: WidgetItem[] = [ { @@ -215,4 +283,40 @@ describe('renderer dim styling', () => { expect(line).toContain(`${DIM}(42%)${INTENSITY_RESET}`); }); + + it('dims parens spans in powerline mode with a global foreground gradient', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'ctx (42%)', + color: 'white', + backgroundColor: 'bgBlue', + dim: 'parens' + } + ]; + + const line = renderLine(widgets, { + settings: { + colorLevel: 3, + overrideForegroundColor: 'gradient:FF0000-0000FF', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + const dimIndex = line.indexOf(DIM); + const openParenIndex = line.indexOf('('); + const closeParenIndex = line.indexOf(')'); + const resetIndex = line.indexOf(INTENSITY_RESET, closeParenIndex); + + expect(getVisibleText(line)).toContain('ctx (42%)'); + expect(dimIndex).toBeGreaterThanOrEqual(0); + expect(dimIndex).toBeLessThan(openParenIndex); + expect(resetIndex).toBeGreaterThan(closeParenIndex); + expect(line.match(TRUECOLOR_CODE)?.length ?? 0).toBeGreaterThan(1); + }); }); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 618b36af..7ab843e9 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -131,8 +131,8 @@ export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16 // Dim each (...) span within the text. \x1b[22m clears bold along with dim, // so bold is re-asserted after each span when the surrounding text is bold. export function applyParensDim(text: string, bold?: boolean): string { - const restoreBold = bold ? '\x1b[1m' : ''; - return text.replace(/\([^()]*\)/g, span => `\x1b[2m${span}\x1b[22m${restoreBold}`); + const intensityReset = bold ? '\x1b[22;1m' : '\x1b[22m'; + return text.replace(/\([^()]*\)/g, span => `\x1b[2m${span}${intensityReset}`); } export function applyColors( @@ -184,7 +184,7 @@ export function applyColors( // as the prefix guard. const gradientStops = parseGradientSpec(foregroundColor); if (gradientStops && colorLevel !== 'ansi16') { - return prefix + applyGradientToText(text, gradientStops, colorLevel) + '\x1b[39m' + suffix; + return prefix + applyGradientToText(styledText, gradientStops, colorLevel) + '\x1b[39m' + suffix; } const fgCode = getColorAnsiCode(foregroundColor, colorLevel, false); diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 0d066a9b..fc18c39a 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -328,7 +328,7 @@ function renderPowerlineStatusLine( const shouldDim = widget.widget.dim === true; // Check if we need a separator after this widget - const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget && !widget.widget.merge; + const needsSeparator = i < widgetElements.length - 1 && separators.length > 0 && nextWidget !== undefined && !widget.widget.merge; let widgetContent = ''; @@ -344,6 +344,9 @@ function renderPowerlineStatusLine( const textGradientStops = !isPreserveColors && powerlineGradientWidth > 1 ? overrideForegroundGradientStops : null; + const styledContent = widget.widget.dim === 'parens' && !isPreserveColors + ? applyParensDim(widget.content, shouldBold) + : widget.content; if (widget.fgColor && !isPreserveColors && !textGradientStops) { widgetContent += getColorAnsiCode(widget.fgColor, colorLevel, false); @@ -354,7 +357,7 @@ function renderPowerlineStatusLine( } if (textGradientStops) { const gradientResult = applyLineGradientSegment( - widget.content, + styledContent, textGradientStops, colorLevel, powerlineGradientColumn, @@ -362,10 +365,8 @@ function renderPowerlineStatusLine( ); widgetContent += gradientResult.text; powerlineGradientColumn = gradientResult.nextColumn; - } else if (widget.widget.dim === 'parens' && !isPreserveColors) { - widgetContent += applyParensDim(widget.content, shouldBold); } else { - widgetContent += widget.content; + widgetContent += styledContent; } // Reset colors after content // For custom commands with preserveColors, also reset text attributes like dim @@ -374,10 +375,14 @@ function renderPowerlineStatusLine( widgetContent += '\x1b[0m'; } else { widgetContent += '\x1b[49m\x1b[39m'; - // Only reset bold/dim if there's no separator following AND no end cap + // Dim should be scoped to the widget text only. Reset before + // separators/end caps so faint intensity cannot leak forward. const isLastWidget = i === widgetElements.length - 1; const hasEndCap = endCaps.length > 0 && endCaps[capLineIndex % endCaps.length]; - if ((shouldBold || shouldDim) && !needsSeparator && !(isLastWidget && hasEndCap)) { + const shouldRestoreBoldForBoundary = shouldDim && shouldBold && (needsSeparator ? true : isLastWidget && hasEndCap); + if (shouldRestoreBoldForBoundary) { + widgetContent += '\x1b[22;1m'; + } else if (shouldDim || (shouldBold && !needsSeparator && !(isLastWidget && hasEndCap))) { widgetContent += '\x1b[22m'; } } @@ -478,6 +483,8 @@ function renderPowerlineStatusLine( // Add end cap if specified if (endCap && widgetElements.length > 0) { const lastWidget = widgetElements[widgetElements.length - 1]; + const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold; + const lastWidgetDim = lastWidget?.widget.dim === true; if (lastWidget?.bgColor) { // End cap uses last widget's background as foreground (converted) @@ -489,8 +496,6 @@ function renderPowerlineStatusLine( } // Reset bold/dim after end cap if needed - const lastWidgetBold = (settings.globalBold) || lastWidget?.widget.bold; - const lastWidgetDim = lastWidget?.widget.dim === true; if (lastWidgetBold || lastWidgetDim) { result += '\x1b[22m'; }