diff --git a/README.md b/README.md
index 8dfcf23a..6ce460ca 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,8 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`).
Default: the name of the process, or its index if no name is set.
- `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.
+ Supports all Chalk color functions: `#RRGGBB`, `bg#RRGGBB`, `hex()`, `bgHex()`, `rgb()`, `bgRgb()`, `ansi256()`, `bgAnsi256()`.
+ Functions and modifiers can be chained (e.g., `rgb(255,136,0).bold`, `black.bgHex(#00FF00).dim`).
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.
Prefix colors specified per-command take precedence over this list.
- `prefixLength`: how many characters to show when prefixing with `command`. Default: `10`
diff --git a/bin/index.ts b/bin/index.ts
index f8220167..19ba5d28 100755
--- a/bin/index.ts
+++ b/bin/index.ts
@@ -7,7 +7,7 @@ import { hideBin } from 'yargs/helpers';
import { assertDeprecated } from '../lib/assert.js';
import * as defaults from '../lib/defaults.js';
import { concurrently } from '../lib/index.js';
-import { castArray } from '../lib/utils.js';
+import { castArray, splitOutsideParens } from '../lib/utils.js';
import { readPackageJson } from './read-package-json.js';
const version = String(readPackageJson().version);
@@ -256,7 +256,7 @@ concurrently(
hide: args.hide.split(','),
group: args.group,
prefix: args.prefix,
- prefixColors: args.prefixColors.split(','),
+ prefixColors: splitOutsideParens(args.prefixColors, ','),
prefixLength: args.prefixLength,
padPrefix: args.padPrefix,
restartDelay:
diff --git a/docs/cli/prefixing.md b/docs/cli/prefixing.md
index c22b638a..48d9a477 100644
--- a/docs/cli/prefixing.md
+++ b/docs/cli/prefixing.md
@@ -118,6 +118,34 @@ $ concurrently -c bgGray,red.bgBlack 'echo Hello there' 'echo General Kenobi!'
- `bgYellow`
+### Advanced Color Functions
+
+concurrently supports all [Chalk color functions](https://github.com/chalk/chalk#256-and-truecolor-color-support):
+
+| Function | Description |
+| ---------------- | --------------------------- |
+| `#RRGGBB` | Foreground hex (shorthand) |
+| `bg#RRGGBB` | Background hex (shorthand) |
+| `hex(#RRGGBB)` | Foreground hex |
+| `bgHex(#RRGGBB)` | Background hex |
+| `rgb(R,G,B)` | Foreground RGB (0-255) |
+| `bgRgb(R,G,B)` | Background RGB (0-255) |
+| `ansi256(N)` | Foreground ANSI 256 (0-255) |
+| `bgAnsi256(N)` | Background ANSI 256 (0-255) |
+
+All functions can be chained with colors and modifiers:
+
+```bash
+# Hex colors
+$ concurrently -c 'bg#FF0000.bold,black.bgHex(#00FF00).dim' 'echo Red bg' 'echo Green bg'
+
+# RGB colors
+$ concurrently -c 'rgb(255,136,0).bold,black.bgRgb(100,100,255)' 'echo Orange' 'echo Blue bg'
+
+# ANSI 256 colors
+$ concurrently -c 'ansi256(199),ansi256(50).bgAnsi256(17)' 'echo Pink' 'echo Cyan on blue'
+```
+
## Prefix Length
When using the `command` prefix style, it's possible that it'll be too long.
diff --git a/lib/logger.spec.ts b/lib/logger.spec.ts
index 70406f81..1c287943 100644
--- a/lib/logger.spec.ts
+++ b/lib/logger.spec.ts
@@ -257,6 +257,208 @@ describe('#logCommandText()', () => {
);
});
+ it('logs prefix using prefixColor from command if prefixColor is a bg hex value (short form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bg#32bd8a',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#32bd8a')('[1]')} `, 'foo', cmd);
+ });
+
+ it('logs prefix using prefixColor from command if prefixColor is a bg hex value with modifiers (short form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bg#32bd8a.bold',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.bgHex('#32bd8a').bold('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('handles 3-digit hex codes for bg hex (short form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bg#f00',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#f00')('[1]')} `, 'foo', cmd);
+ });
+
+ it('logs prefix using prefixColor from command if prefixColor is a bgHex() value (explicit form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bgHex(#ff5500)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#ff5500')('[1]')} `, 'foo', cmd);
+ });
+
+ it('logs prefix using prefixColor from command if prefixColor is a bgHex() value with modifiers (explicit form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bgHex(#ff5500).dim',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.bgHex('#ff5500').dim('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('handles 3-digit hex codes for bgHex() (explicit form)', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bgHex(#0f0)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.bgHex('#0f0')('[1]')} `, 'foo', cmd);
+ });
+
+ it('falls back to default color for malformed bgHex() syntax', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'bgHex(invalid)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.reset('[1]')} `, 'foo', cmd);
+ });
+
+ it('logs prefix with chained fgColor.bgHex().modifier pattern', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'black.bgHex(#533AFD).dim',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.black.bgHex('#533AFD').dim('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix with chained fgColor.bg#HEXCODE.modifier pattern', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'black.bg#FF0000.bold',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.black.bgHex('#FF0000').bold('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix with chained #HEXCODE.bgNamed.modifier pattern', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: '#FF0000.bgBlue.dim',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.hex('#FF0000').bgBlue.dim('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix using rgb() color function', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'rgb(255,136,0).bold',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.rgb(255, 136, 0).bold('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix using bgRgb() color function', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'black.bgRgb(100,100,255)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.black.bgRgb(100, 100, 255)('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix using ansi256() color function', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'ansi256(199)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.ansi256(199)('[1]')} `, 'foo', cmd);
+ });
+
+ it('logs prefix using bgAnsi256() color function', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'ansi256(199).bgAnsi256(50)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(
+ `${chalk.ansi256(199).bgAnsi256(50)('[1]')} `,
+ 'foo',
+ cmd,
+ );
+ });
+
+ it('logs prefix using hex() explicit function', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'hex(#ff5500)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.hex('#ff5500')('[1]')} `, 'foo', cmd);
+ });
+
+ it('falls back to default color for malformed hex() syntax', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'hex(invalid)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.reset('[1]')} `, 'foo', cmd);
+ });
+
+ it('falls back to default color for unknown function name', () => {
+ const { logger } = createLogger({});
+ const cmd = new FakeCommand('', undefined, 1, {
+ prefixColor: 'unknownFunc(123)',
+ });
+ logger.logCommandText('foo', cmd);
+
+ expect(logger.log).toHaveBeenCalledWith(`${chalk.reset('[1]')} `, 'foo', cmd);
+ });
+
it('does nothing if command is hidden by name', () => {
const { logger } = createLogger({ hide: ['abc'] });
const cmd = new FakeCommand('abc');
diff --git a/lib/logger.ts b/lib/logger.ts
index d2e3bdb5..81beb8fc 100644
--- a/lib/logger.ts
+++ b/lib/logger.ts
@@ -4,18 +4,72 @@ import Rx from 'rxjs';
import { Command, CommandIdentifier } from './command.js';
import { DateFormatter } from './date-format.js';
import * as defaults from './defaults.js';
-import { escapeRegExp } from './utils.js';
+import { escapeRegExp, splitOutsideParens } from './utils.js';
const defaultChalk = chalk;
const noColorChalk = new Chalk({ level: 0 });
-function getChalkPath(chalk: ChalkInstance, path: string): ChalkInstance | undefined {
- return path
- .split('.')
- .reduce(
- (prev, key) => prev && (prev as unknown as Record)[key],
- chalk,
- );
+const HEX_PATTERN = /^#[0-9A-Fa-f]{3,6}$/;
+
+/**
+ * Applies a single color segment to a chalk instance.
+ * Handles: function calls (hex, bgHex, rgb, bgRgb, ansi256, bgAnsi256, etc.),
+ * shorthands (#HEX, bg#HEX), and named colors/modifiers.
+ */
+function applySegment(color: ChalkInstance, segment: string): ChalkInstance | undefined {
+ // Function call: name(args) - handles chalk color functions
+ const fnMatch = segment.match(/^(\w+)\((.+)\)$/);
+ if (fnMatch) {
+ const [, fnName, argsStr] = fnMatch;
+ const args = argsStr.split(',').map((a) => {
+ const t = a.trim();
+ return /^\d+$/.test(t) ? parseInt(t, 10) : t;
+ });
+
+ // Explicit function calls for known chalk color functions
+ switch (fnName) {
+ case 'rgb':
+ return color.rgb(args[0] as number, args[1] as number, args[2] as number);
+ case 'bgRgb':
+ return color.bgRgb(args[0] as number, args[1] as number, args[2] as number);
+ case 'hex':
+ if (!HEX_PATTERN.test(args[0] as string)) return undefined;
+ return color.hex(args[0] as string);
+ case 'bgHex':
+ if (!HEX_PATTERN.test(args[0] as string)) return undefined;
+ return color.bgHex(args[0] as string);
+ case 'ansi256':
+ return color.ansi256(args[0] as number);
+ case 'bgAnsi256':
+ return color.bgAnsi256(args[0] as number);
+ default:
+ return undefined;
+ }
+ }
+
+ // Shorthands
+ if (segment.startsWith('bg#')) return color.bgHex(segment.slice(2));
+ if (segment.startsWith('#')) return color.hex(segment);
+
+ // Property: black, bold, dim, etc.
+ return (color as unknown as Record)[segment] ?? undefined;
+}
+
+/**
+ * Applies a color string to chalk, supporting chained colors and modifiers.
+ * Returns undefined if any segment is invalid (triggers fallback to default).
+ */
+function applyColor(chalkInstance: ChalkInstance, colorString: string): ChalkInstance | undefined {
+ const segments = splitOutsideParens(colorString, '.');
+ if (segments.length === 0) return undefined;
+
+ let color: ChalkInstance = chalkInstance;
+ for (const segment of segments) {
+ const next = applySegment(color, segment);
+ if (!next) return undefined;
+ color = next;
+ }
+ return color;
}
export class Logger {
@@ -157,18 +211,9 @@ export class Logger {
}
colorText(command: Command, text: string) {
- let color: ChalkInstance;
- if (command.prefixColor?.startsWith('#')) {
- const [hexColor, ...modifiers] = command.prefixColor.split('.');
- color = this.chalk.hex(hexColor);
- const modifiedColor = getChalkPath(color, modifiers.join('.'));
- if (modifiedColor) {
- color = modifiedColor;
- }
- } else {
- const defaultColor = getChalkPath(this.chalk, defaults.prefixColors) as ChalkInstance;
- color = getChalkPath(this.chalk, command.prefixColor ?? '') ?? defaultColor;
- }
+ const prefixColor = command.prefixColor ?? '';
+ const defaultColor = applyColor(this.chalk, defaults.prefixColors) as ChalkInstance;
+ const color = applyColor(this.chalk, prefixColor) ?? defaultColor;
return color(text);
}
diff --git a/lib/utils.spec.ts b/lib/utils.spec.ts
index 014156c3..b4adb09c 100644
--- a/lib/utils.spec.ts
+++ b/lib/utils.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { castArray, escapeRegExp } from './utils.js';
+import { castArray, escapeRegExp, splitOutsideParens } from './utils.js';
describe('#escapeRegExp()', () => {
it('escapes all RegExp chars', () => {
@@ -37,3 +37,37 @@ describe('#castArray()', () => {
});
});
});
+
+describe('#splitOutsideParens()', () => {
+ it('splits on the given delimiter', () => {
+ expect(splitOutsideParens('red,blue', ',')).toEqual(['red', 'blue']);
+ });
+
+ it('preserves delimiters inside parentheses', () => {
+ expect(splitOutsideParens('red,rgb(255,0,0),blue', ',')).toEqual([
+ 'red',
+ 'rgb(255,0,0)',
+ 'blue',
+ ]);
+ });
+
+ it('splits chalk-style dotted color paths, preserving function calls', () => {
+ expect(splitOutsideParens('black.bgHex(#533AFD).dim', '.')).toEqual([
+ 'black',
+ 'bgHex(#533AFD)',
+ 'dim',
+ ]);
+ });
+
+ it('trims whitespace around each segment', () => {
+ expect(splitOutsideParens(' red , blue ', ',')).toEqual(['red', 'blue']);
+ });
+
+ it('drops empty segments', () => {
+ expect(splitOutsideParens(',,red,,', ',')).toEqual(['red']);
+ });
+
+ it('returns an empty array for an empty input', () => {
+ expect(splitOutsideParens('', ',')).toEqual([]);
+ });
+});
diff --git a/lib/utils.ts b/lib/utils.ts
index 831d46b8..068b0b64 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -13,3 +13,31 @@ type CastArrayResult = T extends undefined | null ? never[] : T extends unkno
export function castArray(value?: T) {
return (Array.isArray(value) ? value : value != null ? [value] : []) as CastArrayResult;
}
+
+/**
+ * Splits a string on `delimiter`, ignoring delimiters inside parentheses.
+ * Trims each segment and discards empty ones.
+ *
+ * Examples:
+ * splitOutsideParens('red,rgb(255,0,0),blue', ',') → ['red', 'rgb(255,0,0)', 'blue']
+ * splitOutsideParens('black.bgHex(#533AFD).dim', '.') → ['black', 'bgHex(#533AFD)', 'dim']
+ */
+export function splitOutsideParens(input: string, delimiter: string): string[] {
+ const segments: string[] = [];
+ let current = '';
+ let parenDepth = 0;
+ for (const char of input) {
+ if (char === '(') parenDepth++;
+ else if (char === ')') parenDepth--;
+ if (char === delimiter && parenDepth === 0) {
+ const trimmed = current.trim();
+ if (trimmed) segments.push(trimmed);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ const trimmed = current.trim();
+ if (trimmed) segments.push(trimmed);
+ return segments;
+}