From 20aa94501b120814756d02f8a59c2113b64fc5f3 Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Sun, 14 Dec 2025 15:28:36 +0100 Subject: [PATCH 1/2] 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. --- README.md | 2 + bin/index.ts | 24 ++++- docs/cli/prefixing.md | 28 ++++++ lib/logger.spec.ts | 202 ++++++++++++++++++++++++++++++++++++++++++ lib/logger.ts | 106 ++++++++++++++++++---- 5 files changed, 342 insertions(+), 20 deletions(-) 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..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 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..6c5d71d6 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -9,13 +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 && (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 { @@ -157,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); } From dafbc3ebd441f7f1984d45e85fdbde13ca796075 Mon Sep 17 00:00:00 2001 From: Stephan Schubert Date: Fri, 17 Apr 2026 15:47:55 +0200 Subject: [PATCH 2/2] refactor: extract splitOutsideParens shared helper The CLI's comma-splitter for color args and the logger's dot-splitter for color path segments shared near-identical paren-aware string walking. Extract the shared logic into lib/utils.ts, parameterized on the delimiter, and wire up both call sites. --- bin/index.ts | 26 ++------------------------ lib/logger.ts | 27 ++------------------------- lib/utils.spec.ts | 36 +++++++++++++++++++++++++++++++++++- lib/utils.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 50 deletions(-) diff --git a/bin/index.ts b/bin/index.ts index 22286380..19ba5d28 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -7,31 +7,9 @@ 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'; -/** - * 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`; @@ -278,7 +256,7 @@ concurrently( hide: args.hide.split(','), group: args.group, prefix: args.prefix, - prefixColors: splitColorArgs(args.prefixColors), + prefixColors: splitOutsideParens(args.prefixColors, ','), prefixLength: args.prefixLength, padPrefix: args.padPrefix, restartDelay: diff --git a/lib/logger.ts b/lib/logger.ts index 6c5d71d6..81beb8fc 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -4,34 +4,11 @@ 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 }); -/** - * 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}$/; /** @@ -83,7 +60,7 @@ function applySegment(color: ChalkInstance, segment: string): ChalkInstance | un * Returns undefined if any segment is invalid (triggers fallback to default). */ function applyColor(chalkInstance: ChalkInstance, colorString: string): ChalkInstance | undefined { - const segments = parseColorSegments(colorString); + const segments = splitOutsideParens(colorString, '.'); if (segments.length === 0) return undefined; let color: ChalkInstance = chalkInstance; 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; +}