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; +}