Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/tui/components/ColorMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ConfirmDialog } from './ConfirmDialog';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
setWidgetColor,
toggleWidgetBold
Expand Down Expand Up @@ -268,6 +269,15 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ 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
Expand Down Expand Up @@ -347,7 +357,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ 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
Expand Down Expand Up @@ -431,6 +441,11 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ 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) {
Expand Down Expand Up @@ -573,7 +588,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ 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,' : ''}
{' '}
Expand All @@ -597,7 +612,7 @@ export const ColorMenu: React.FC<ColorMenuProps> = ({ widgets, lineIndex, settin
):
{' '}
{colorDisplay}
{selectedWidget.bold && chalk.bold(' [BOLD]')}
{styleIndicators && ` ${styleIndicators}`}
</Text>
</Box>
) : (
Expand Down
122 changes: 122 additions & 0 deletions src/tui/components/__tests__/ColorMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
134 changes: 133 additions & 1 deletion src/tui/components/__tests__/StatusLinePreview.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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();
}
});
});
27 changes: 23 additions & 4 deletions src/tui/components/color-menu/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { WidgetItem } from '../../../../types/Widget';
import {
clearAllWidgetStyling,
cycleWidgetColor,
cycleWidgetDim,
resetWidgetStyling,
toggleWidgetBold,
updateWidgetById
Expand Down Expand Up @@ -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 }
];
Expand All @@ -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);
Expand Down
Loading