Skip to content

Commit 60300e8

Browse files
feat: add support for Chalk color functions in prefix colors
Add support for all Chalk color functions: hex(), bgHex(), rgb(), bgRgb(), ansi256(), bgAnsi256(), plus shorthand syntax #RRGGBB and bg#RRGGBB. Functions and modifiers can be chained (e.g., rgb(255,136,0).bold, black.bgHex(#00FF00).dim). The CLI now correctly parses color arguments containing commas inside function calls like rgb(255,255,0) by using a parenthesis-aware splitter.
1 parent 2c30e74 commit 60300e8

File tree

5 files changed

+316
-17
lines changed

5 files changed

+316
-17
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
105105
Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`).
106106
Default: the name of the process, or its index if no name is set.
107107
- `prefixColors`: a list of colors or a string as supported by [Chalk](https://www.npmjs.com/package/chalk) and additional style `auto` for an automatically picked color.
108+
Supports all Chalk color functions: `#RRGGBB`, `bg#RRGGBB`, `hex()`, `bgHex()`, `rgb()`, `bgRgb()`, `ansi256()`, `bgAnsi256()`.
109+
Functions and modifiers can be chained (e.g., `rgb(255,136,0).bold`, `black.bgHex(#00FF00).dim`).
108110
If concurrently would run more commands than there are colors, the last color is repeated, unless if the last color value is `auto` which means following colors are automatically picked to vary.
109111
Prefix colors specified per-command take precedence over this list.
110112
- `prefixLength`: how many characters to show when prefixing with `command`. Default: `10`

bin/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ import { concurrently } from '../lib/index.js';
1010
import { castArray } from '../lib/utils.js';
1111
import { readPackageJson } from './read-package-json.js';
1212

13+
/**
14+
* Splits a color arguments string on commas, but preserves commas inside parentheses.
15+
* e.g., "red,rgb(255,255,0),blue" → ["red", "rgb(255,255,0)", "blue"]
16+
*/
17+
function splitColorArgs(input: string): string[] {
18+
const colors: string[] = [];
19+
let current = '';
20+
let parenDepth = 0;
21+
for (const char of input) {
22+
if (char === '(') parenDepth++;
23+
if (char === ')') parenDepth--;
24+
if (char === ',' && parenDepth === 0) {
25+
if (current.trim()) colors.push(current.trim());
26+
current = '';
27+
} else {
28+
current += char;
29+
}
30+
}
31+
if (current.trim()) colors.push(current.trim());
32+
return colors;
33+
}
34+
1335
const version = String(readPackageJson().version);
1436
const epilogue = `For documentation and more examples, visit:\nhttps://github.com/open-cli-tools/concurrently/tree/v${version}/docs`;
1537

@@ -256,7 +278,7 @@ concurrently(
256278
hide: args.hide.split(','),
257279
group: args.group,
258280
prefix: args.prefix,
259-
prefixColors: args.prefixColors.split(','),
281+
prefixColors: splitColorArgs(args.prefixColors),
260282
prefixLength: args.prefixLength,
261283
padPrefix: args.padPrefix,
262284
restartDelay:

docs/cli/prefixing.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ $ concurrently -c bgGray,red.bgBlack 'echo Hello there' 'echo General Kenobi!'
118118
- `bgYellow`
119119
</details>
120120

121+
### Advanced Color Functions
122+
123+
concurrently supports all [Chalk color functions](https://github.com/chalk/chalk#256-and-truecolor-color-support):
124+
125+
| Function | Description |
126+
| ---------------- | --------------------------- |
127+
| `#RRGGBB` | Foreground hex (shorthand) |
128+
| `bg#RRGGBB` | Background hex (shorthand) |
129+
| `hex(#RRGGBB)` | Foreground hex |
130+
| `bgHex(#RRGGBB)` | Background hex |
131+
| `rgb(R,G,B)` | Foreground RGB (0-255) |
132+
| `bgRgb(R,G,B)` | Background RGB (0-255) |
133+
| `ansi256(N)` | Foreground ANSI 256 (0-255) |
134+
| `bgAnsi256(N)` | Background ANSI 256 (0-255) |
135+
136+
All functions can be chained with colors and modifiers:
137+
138+
```bash
139+
# Hex colors
140+
$ concurrently -c 'bg#FF0000.bold,black.bgHex(#00FF00).dim' 'echo Red bg' 'echo Green bg'
141+
142+
# RGB colors
143+
$ concurrently -c 'rgb(255,136,0).bold,black.bgRgb(100,100,255)' 'echo Orange' 'echo Blue bg'
144+
145+
# ANSI 256 colors
146+
$ concurrently -c 'ansi256(199),ansi256(50).bgAnsi256(17)' 'echo Pink' 'echo Cyan on blue'
147+
```
148+
121149
## Prefix Length
122150

123151
When using the `command` prefix style, it's possible that it'll be too long.<br/>

lib/logger.spec.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,178 @@ describe('#logCommandText()', () => {
247247
);
248248
});
249249

250+
it('logs prefix using prefixColor from command if prefixColor is a bg hex value (short form)', () => {
251+
const { logger } = createLogger({});
252+
const cmd = new FakeCommand('', undefined, 1, {
253+
prefixColor: 'bg#32bd8a',
254+
});
255+
logger.logCommandText('foo', cmd);
256+
257+
expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#32bd8a')('[1]')} `, 'foo', cmd);
258+
});
259+
260+
it('logs prefix using prefixColor from command if prefixColor is a bg hex value with modifiers (short form)', () => {
261+
const { logger } = createLogger({});
262+
const cmd = new FakeCommand('', undefined, 1, {
263+
prefixColor: 'bg#32bd8a.bold',
264+
});
265+
logger.logCommandText('foo', cmd);
266+
267+
expect(logger.log).toHaveBeenCalledWith(
268+
`${chalk.bgHex('#32bd8a').bold('[1]')} `,
269+
'foo',
270+
cmd,
271+
);
272+
});
273+
274+
it('handles 3-digit hex codes for bg hex (short form)', () => {
275+
const { logger } = createLogger({});
276+
const cmd = new FakeCommand('', undefined, 1, {
277+
prefixColor: 'bg#f00',
278+
});
279+
logger.logCommandText('foo', cmd);
280+
281+
expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#f00')('[1]')} `, 'foo', cmd);
282+
});
283+
284+
it('logs prefix using prefixColor from command if prefixColor is a bgHex() value (explicit form)', () => {
285+
const { logger } = createLogger({});
286+
const cmd = new FakeCommand('', undefined, 1, {
287+
prefixColor: 'bgHex(#ff5500)',
288+
});
289+
logger.logCommandText('foo', cmd);
290+
291+
expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#ff5500')('[1]')} `, 'foo', cmd);
292+
});
293+
294+
it('logs prefix using prefixColor from command if prefixColor is a bgHex() value with modifiers (explicit form)', () => {
295+
const { logger } = createLogger({});
296+
const cmd = new FakeCommand('', undefined, 1, {
297+
prefixColor: 'bgHex(#ff5500).dim',
298+
});
299+
logger.logCommandText('foo', cmd);
300+
301+
expect(logger.log).toHaveBeenCalledWith(
302+
`${chalk.bgHex('#ff5500').dim('[1]')} `,
303+
'foo',
304+
cmd,
305+
);
306+
});
307+
308+
it('handles 3-digit hex codes for bgHex() (explicit form)', () => {
309+
const { logger } = createLogger({});
310+
const cmd = new FakeCommand('', undefined, 1, {
311+
prefixColor: 'bgHex(#0f0)',
312+
});
313+
logger.logCommandText('foo', cmd);
314+
315+
expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#0f0')('[1]')} `, 'foo', cmd);
316+
});
317+
318+
it('falls back to default color for malformed bgHex() syntax', () => {
319+
const { logger } = createLogger({});
320+
const cmd = new FakeCommand('', undefined, 1, {
321+
prefixColor: 'bgHex(invalid)',
322+
});
323+
logger.logCommandText('foo', cmd);
324+
325+
expect(logger.log).toHaveBeenCalledWith(`${chalk.reset('[1]')} `, 'foo', cmd);
326+
});
327+
328+
it('logs prefix with chained fgColor.bgHex().modifier pattern', () => {
329+
const { logger } = createLogger({});
330+
const cmd = new FakeCommand('', undefined, 1, {
331+
prefixColor: 'black.bgHex(#533AFD).dim',
332+
});
333+
logger.logCommandText('foo', cmd);
334+
335+
expect(logger.log).toHaveBeenCalledWith(
336+
`${chalk.black.bgHex('#533AFD').dim('[1]')} `,
337+
'foo',
338+
cmd,
339+
);
340+
});
341+
342+
it('logs prefix with chained fgColor.bg#HEXCODE.modifier pattern', () => {
343+
const { logger } = createLogger({});
344+
const cmd = new FakeCommand('', undefined, 1, {
345+
prefixColor: 'black.bg#FF0000.bold',
346+
});
347+
logger.logCommandText('foo', cmd);
348+
349+
expect(logger.log).toHaveBeenCalledWith(
350+
`${chalk.black.bgHex('#FF0000').bold('[1]')} `,
351+
'foo',
352+
cmd,
353+
);
354+
});
355+
356+
it('logs prefix with chained #HEXCODE.bgNamed.modifier pattern', () => {
357+
const { logger } = createLogger({});
358+
const cmd = new FakeCommand('', undefined, 1, {
359+
prefixColor: '#FF0000.bgBlue.dim',
360+
});
361+
logger.logCommandText('foo', cmd);
362+
363+
expect(logger.log).toHaveBeenCalledWith(
364+
`${chalk.hex('#FF0000').bgBlue.dim('[1]')} `,
365+
'foo',
366+
cmd,
367+
);
368+
});
369+
370+
it('logs prefix using rgb() color function', () => {
371+
const { logger } = createLogger({});
372+
const cmd = new FakeCommand('', undefined, 1, {
373+
prefixColor: 'rgb(255,136,0).bold',
374+
});
375+
logger.logCommandText('foo', cmd);
376+
377+
expect(logger.log).toHaveBeenCalledWith(
378+
`${chalk.rgb(255, 136, 0).bold('[1]')} `,
379+
'foo',
380+
cmd,
381+
);
382+
});
383+
384+
it('logs prefix using bgRgb() color function', () => {
385+
const { logger } = createLogger({});
386+
const cmd = new FakeCommand('', undefined, 1, {
387+
prefixColor: 'black.bgRgb(100,100,255)',
388+
});
389+
logger.logCommandText('foo', cmd);
390+
391+
expect(logger.log).toHaveBeenCalledWith(
392+
`${chalk.black.bgRgb(100, 100, 255)('[1]')} `,
393+
'foo',
394+
cmd,
395+
);
396+
});
397+
398+
it('logs prefix using ansi256() color function', () => {
399+
const { logger } = createLogger({});
400+
const cmd = new FakeCommand('', undefined, 1, {
401+
prefixColor: 'ansi256(199)',
402+
});
403+
logger.logCommandText('foo', cmd);
404+
405+
expect(logger.log).toHaveBeenCalledWith(`${chalk.ansi256(199)('[1]')} `, 'foo', cmd);
406+
});
407+
408+
it('logs prefix using bgAnsi256() color function', () => {
409+
const { logger } = createLogger({});
410+
const cmd = new FakeCommand('', undefined, 1, {
411+
prefixColor: 'ansi256(199).bgAnsi256(50)',
412+
});
413+
logger.logCommandText('foo', cmd);
414+
415+
expect(logger.log).toHaveBeenCalledWith(
416+
`${chalk.ansi256(199).bgAnsi256(50)('[1]')} `,
417+
'foo',
418+
cmd,
419+
);
420+
});
421+
250422
it('does nothing if command is hidden by name', () => {
251423
const { logger } = createLogger({ hide: ['abc'] });
252424
const cmd = new FakeCommand('abc');

lib/logger.ts

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,94 @@ import { escapeRegExp } from './utils.js';
99
const defaultChalk = chalk;
1010
const noColorChalk = new Chalk({ level: 0 });
1111

12-
function getChalkPath(chalk: ChalkInstance, path: string): ChalkInstance | undefined {
13-
return path
14-
.split('.')
15-
.reduce((prev, key) => (prev as unknown as Record<string, ChalkInstance>)[key], chalk);
12+
/**
13+
* Parses a color string into segments, preserving function calls as single tokens.
14+
* e.g., "black.bgHex(#533AFD).dim" → ["black", "bgHex(#533AFD)", "dim"]
15+
*/
16+
function parseColorSegments(colorString: string): string[] {
17+
const segments: string[] = [];
18+
let current = '';
19+
let parenDepth = 0;
20+
21+
for (const char of colorString) {
22+
if (char === '(') parenDepth++;
23+
if (char === ')') parenDepth--;
24+
if (char === '.' && parenDepth === 0) {
25+
if (current) segments.push(current);
26+
current = '';
27+
} else {
28+
current += char;
29+
}
30+
}
31+
if (current) segments.push(current);
32+
return segments;
33+
}
34+
35+
const HEX_PATTERN = /^#[0-9A-Fa-f]{3,6}$/;
36+
37+
/**
38+
* Applies a single color segment to a chalk instance.
39+
* Handles: function calls (hex, bgHex, rgb, bgRgb, ansi256, bgAnsi256, etc.),
40+
* shorthands (#HEX, bg#HEX), and named colors/modifiers.
41+
*/
42+
function applySegment(color: ChalkInstance, segment: string): ChalkInstance | undefined {
43+
// Function call: name(args) - handles chalk color functions
44+
const fnMatch = segment.match(/^(\w+)\((.+)\)$/);
45+
if (fnMatch) {
46+
const [, fnName, argsStr] = fnMatch;
47+
const args = argsStr.split(',').map((a) => {
48+
const t = a.trim();
49+
return /^\d+$/.test(t) ? parseInt(t, 10) : t;
50+
});
51+
52+
// Explicit function calls for known chalk color functions
53+
switch (fnName) {
54+
case 'rgb':
55+
return color.rgb(args[0] as number, args[1] as number, args[2] as number);
56+
case 'bgRgb':
57+
return color.bgRgb(args[0] as number, args[1] as number, args[2] as number);
58+
case 'hex':
59+
if (!HEX_PATTERN.test(args[0] as string)) return undefined;
60+
return color.hex(args[0] as string);
61+
case 'bgHex':
62+
if (!HEX_PATTERN.test(args[0] as string)) return undefined;
63+
return color.bgHex(args[0] as string);
64+
case 'ansi256':
65+
return color.ansi256(args[0] as number);
66+
case 'bgAnsi256':
67+
return color.bgAnsi256(args[0] as number);
68+
default:
69+
return undefined;
70+
}
71+
}
72+
73+
// Shorthands
74+
if (segment.startsWith('bg#')) return color.bgHex(segment.slice(2));
75+
if (segment.startsWith('#')) return color.hex(segment);
76+
77+
// Property: black, bold, dim, etc.
78+
return (color as unknown as Record<string, ChalkInstance>)[segment] ?? undefined;
79+
}
80+
81+
/**
82+
* Applies a color string to chalk, supporting chained colors and modifiers.
83+
* Returns undefined if any segment is invalid (triggers fallback to default).
84+
*/
85+
function applyColor(chalkInstance: ChalkInstance, colorString: string): ChalkInstance | undefined {
86+
const segments = parseColorSegments(colorString);
87+
if (segments.length === 0) return undefined;
88+
89+
try {
90+
let color: ChalkInstance = chalkInstance;
91+
for (const segment of segments) {
92+
const next = applySegment(color, segment);
93+
if (!next) return undefined;
94+
color = next;
95+
}
96+
return color;
97+
} catch {
98+
return undefined;
99+
}
16100
}
17101

18102
export class Logger {
@@ -154,18 +238,9 @@ export class Logger {
154238
}
155239

156240
colorText(command: Command, text: string) {
157-
let color: ChalkInstance;
158-
if (command.prefixColor?.startsWith('#')) {
159-
const [hexColor, ...modifiers] = command.prefixColor.split('.');
160-
color = this.chalk.hex(hexColor);
161-
const modifiedColor = getChalkPath(color, modifiers.join('.'));
162-
if (modifiedColor) {
163-
color = modifiedColor;
164-
}
165-
} else {
166-
const defaultColor = getChalkPath(this.chalk, defaults.prefixColors) as ChalkInstance;
167-
color = getChalkPath(this.chalk, command.prefixColor ?? '') ?? defaultColor;
168-
}
241+
const prefixColor = command.prefixColor ?? '';
242+
const defaultColor = applyColor(this.chalk, defaults.prefixColors) as ChalkInstance;
243+
const color = applyColor(this.chalk, prefixColor) ?? defaultColor;
169244
return color(text);
170245
}
171246

0 commit comments

Comments
 (0)