diff --git a/README.md b/README.md index 8e79b4fc..2c41ae2d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,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..22286380 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -10,6 +10,28 @@ import { concurrently } from '../lib/index.js'; import { castArray } from '../lib/utils.js'; import { readPackageJson } from './read-package-json.js'; +/** + * Splits a color arguments string on commas, but preserves commas inside parentheses. + * e.g., "red,rgb(255,255,0),blue" → ["red", "rgb(255,255,0)", "blue"] + */ +function splitColorArgs(input: string): string[] { + const colors: string[] = []; + let current = ''; + let parenDepth = 0; + for (const char of input) { + if (char === '(') parenDepth++; + if (char === ')') parenDepth--; + if (char === ',' && parenDepth === 0) { + if (current.trim()) colors.push(current.trim()); + current = ''; + } else { + current += char; + } + } + if (current.trim()) colors.push(current.trim()); + return colors; +} + const version = String(readPackageJson().version); const epilogue = `For documentation and more examples, visit:\nhttps://github.com/open-cli-tools/concurrently/tree/v${version}/docs`; @@ -256,7 +278,7 @@ concurrently( hide: args.hide.split(','), group: args.group, prefix: args.prefix, - prefixColors: args.prefixColors.split(','), + prefixColors: splitColorArgs(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 b2f8f74a..a974bf88 100644 --- a/lib/logger.spec.ts +++ b/lib/logger.spec.ts @@ -247,6 +247,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 2e370081..6c5d71d6 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -9,10 +9,90 @@ import { escapeRegExp } 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 as unknown as Record)[key], chalk); +/** + * Parses a color string into segments, preserving function calls as single tokens. + * e.g., "black.bgHex(#533AFD).dim" → ["black", "bgHex(#533AFD)", "dim"] + */ +function parseColorSegments(colorString: string): string[] { + const segments: string[] = []; + let current = ''; + let parenDepth = 0; + + for (const char of colorString) { + if (char === '(') parenDepth++; + if (char === ')') parenDepth--; + if (char === '.' && parenDepth === 0) { + if (current) segments.push(current); + current = ''; + } else { + current += char; + } + } + if (current) segments.push(current); + return segments; +} + +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 = parseColorSegments(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 { @@ -154,18 +234,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); }