Skip to content

Commit 0e50ac3

Browse files
authored
Partial prefix coloring (#587)
1 parent efb3153 commit 0e50ac3

6 files changed

Lines changed: 138 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
112112
- `prefix`: the prefix type to use when logging processes output.
113113
Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`).
114114
Default: the name of the process, or its index if no name is set.
115+
Templates can wrap any portion of the prefix with `{color}` and `{/color}` to restrict coloring to that region (eg `[{color}{name}{/color}]` colors only the name, leaving the brackets uncolored). If either marker is omitted the missing side is implicit, so a template with no markers is colored in full.
115116
- `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.
116117
Supports all Chalk color functions: `#RRGGBB`, `bg#RRGGBB`, `hex()`, `bgHex()`, `rgb()`, `bgRgb()`, `ansi256()`, `bgAnsi256()`.
117118
Functions and modifiers can be chained (e.g., `rgb(255,136,0).bold`, `black.bgHex(#00FF00).dim`).

docs/cli/prefixing.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,18 @@ $ concurrently -c 'rgb(255,136,0).bold,black.bgRgb(100,100,255)' 'echo Orange' '
146146
$ concurrently -c 'ansi256(199),ansi256(50).bgAnsi256(17)' 'echo Pink' 'echo Cyan on blue'
147147
```
148148

149+
### Scoping the Color to Part of a Template
150+
151+
By default, the entire prefix is colored. When using a template, you can restrict coloring to a specific region by wrapping it with the `{color}` and `{/color}` markers — anything outside the markers is rendered without color.
152+
153+
```bash
154+
$ concurrently -c red,blue --prefix '[{color}{name}{/color}] {pid}' --names one,two 'echo Hello there' 'echo General Kenobi!'
155+
```
156+
157+
In the example above, only `one` and `two` are colored — the surrounding brackets and the PID stay in the terminal's default color.
158+
159+
If only one of the markers is present, the missing side is implicit: an `{color}` without a matching `{/color}` colors everything from the opener to the end of the prefix, and a `{/color}` without a preceding `{color}` colors everything from the start of the prefix up to the closer. A template with neither marker is colored in full, matching the previous behavior.
160+
149161
## Prefix Length
150162

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

lib/flow-control/logger-padding.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ it('sets prefix length to the longest prefix of all commands', () => {
4242
expect(logger.setPrefixLength).toHaveBeenCalledWith(6);
4343
});
4444

45+
it('ignores color markers when measuring prefix length', () => {
46+
logger.getPrefixContent
47+
.mockReturnValueOnce({ type: 'template', value: '{color}foo{/color}' })
48+
.mockReturnValueOnce({ type: 'template', value: '{color}abcd{/color}' });
49+
50+
controller.handle(commands);
51+
expect(logger.setPrefixLength).toHaveBeenCalledWith(4);
52+
});
53+
4554
it('does not shorten the prefix length', () => {
4655
logger.getPrefixContent
4756
.mockReturnValueOnce({ type: 'default', value: '100' })

lib/flow-control/logger-padding.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Command } from '../command.js';
2-
import { Logger } from '../logger.js';
2+
import { COLOR_MARKER_RE, Logger } from '../logger.js';
33
import { FlowController } from './flow-controller.js';
44

5+
function visibleLength(value: string | undefined): number {
6+
return value ? value.replace(COLOR_MARKER_RE, '').length : 0;
7+
}
8+
59
export class LoggerPadding implements FlowController {
610
private readonly logger: Logger;
711

@@ -14,7 +18,7 @@ export class LoggerPadding implements FlowController {
1418
// Compute the prefix length now, which works for all styles but those with a PID.
1519
let length = commands.reduce((length, command) => {
1620
const content = this.logger.getPrefixContent(command);
17-
return Math.max(length, content?.value.length || 0);
21+
return Math.max(length, visibleLength(content?.value));
1822
}, 0);
1923
this.logger.setPrefixLength(length);
2024

@@ -25,7 +29,7 @@ export class LoggerPadding implements FlowController {
2529
command.timer.subscribe((event) => {
2630
if (!event.endDate) {
2731
const content = this.logger.getPrefixContent(command);
28-
length = Math.max(length, content?.value.length || 0);
32+
length = Math.max(length, visibleLength(content?.value));
2933
this.logger.setPrefixLength(length);
3034
}
3135
}),

lib/logger.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,79 @@ describe('#logCommandText()', () => {
476476
});
477477
});
478478

479+
describe('#logCommandText() with color markers', () => {
480+
it('colors only the text inside {color}...{/color} within a template prefix', () => {
481+
const { logger } = createLogger({ prefixFormat: '[{color}{name}{/color}]' });
482+
483+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
484+
logger.logCommandText('foo', cmd);
485+
486+
expect(logger.log).toHaveBeenCalledWith(`[${chalk.blue('bar')}] `, 'foo', cmd);
487+
});
488+
489+
it('supports multiple {color}...{/color} pairs in one template', () => {
490+
const { logger } = createLogger({
491+
prefixFormat: '{color}[{/color}{name}{color}]{/color}',
492+
});
493+
494+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
495+
logger.logCommandText('foo', cmd);
496+
497+
expect(logger.log).toHaveBeenCalledWith(
498+
`${chalk.blue('[')}bar${chalk.blue(']')} `,
499+
'foo',
500+
cmd,
501+
);
502+
});
503+
504+
it('auto-closes an unclosed {color} so the tail stays colored', () => {
505+
const { logger } = createLogger({ prefixFormat: '[{color}{name}]' });
506+
507+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
508+
logger.logCommandText('foo', cmd);
509+
510+
expect(logger.log).toHaveBeenCalledWith(`[${chalk.blue('bar]')} `, 'foo', cmd);
511+
});
512+
513+
it('auto-opens a bare {/color} so the head stays colored', () => {
514+
const { logger } = createLogger({ prefixFormat: '{name}{/color}]' });
515+
516+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
517+
logger.logCommandText('foo', cmd);
518+
519+
expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('bar')}] `, 'foo', cmd);
520+
});
521+
522+
it('templates without markers stay fully colored (backward compat)', () => {
523+
const { logger } = createLogger({ prefixFormat: '{name}-{index}' });
524+
525+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
526+
logger.logCommandText('foo', cmd);
527+
528+
expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('bar-1')} `, 'foo', cmd);
529+
});
530+
531+
it('pads templated prefix based on visible length, ignoring marker tokens', () => {
532+
const { logger } = createLogger({ prefixFormat: '{color}{name}{/color}' });
533+
534+
const cmd = new FakeCommand('foo', undefined, 0, { prefixColor: 'blue' });
535+
logger.setPrefixLength(6);
536+
logger.logCommandText('bar', cmd);
537+
538+
expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('foo')} `, 'bar', cmd);
539+
});
540+
541+
it('strips markers and emits no ANSI escapes when colors are globally off', () => {
542+
const { logger } = createLogger({ prefixFormat: '[{color}{name}{/color}]' });
543+
544+
logger.toggleColors(false);
545+
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
546+
logger.logCommandText('foo', cmd);
547+
548+
expect(logger.log).toHaveBeenCalledWith('[bar] ', 'foo', cmd);
549+
});
550+
});
551+
479552
describe('#logCommandEvent()', () => {
480553
it('does nothing if in raw mode', () => {
481554
const { logger } = createLogger({ raw: true });

lib/logger.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const noColorChalk = new Chalk({ level: 0 });
1111

1212
const HEX_PATTERN = /^#[0-9A-Fa-f]{3,6}$/;
1313

14+
const COLOR_OPEN = '{color}';
15+
const COLOR_CLOSE = '{/color}';
16+
export const COLOR_MARKER_RE = /\{\/?color\}/g;
17+
1418
/**
1519
* Applies a single color segment to a chalk instance.
1620
* Handles: function calls (hex, bgHex, rgb, bgRgb, ansi256, bgAnsi256, etc.),
@@ -201,9 +205,11 @@ export class Logger {
201205
return '';
202206
}
203207

208+
const visibleLength = content.value.replace(COLOR_MARKER_RE, '').length;
209+
const padding = ' '.repeat(Math.max(0, this.prefixLength - visibleLength));
204210
return content.type === 'template'
205-
? content.value.padEnd(this.prefixLength, ' ')
206-
: `[${content.value.padEnd(this.prefixLength, ' ')}]`;
211+
? content.value + padding
212+
: `[${content.value}${padding}]`;
207213
}
208214

209215
setPrefixLength(length: number) {
@@ -214,7 +220,34 @@ export class Logger {
214220
const prefixColor = command.prefixColor ?? '';
215221
const defaultColor = applyColor(this.chalk, defaults.prefixColors) ?? this.chalk.reset;
216222
const color = applyColor(this.chalk, prefixColor) ?? defaultColor;
217-
return color(text);
223+
224+
// Segment the text around `{color}` / `{/color}` markers and only apply `color`
225+
// inside opened regions. If either marker is missing, it's implicitly added to
226+
// the start or end respectively — so a marker-free input stays fully colored,
227+
// preserving backward compatibility.
228+
let normalized = text;
229+
if (!normalized.includes(COLOR_OPEN)) normalized = COLOR_OPEN + normalized;
230+
if (!normalized.includes(COLOR_CLOSE)) normalized = normalized + COLOR_CLOSE;
231+
232+
let output = '';
233+
let rest = normalized;
234+
let inColorRegion = false;
235+
while (rest.length > 0) {
236+
const marker = inColorRegion ? COLOR_CLOSE : COLOR_OPEN;
237+
const idx = rest.indexOf(marker);
238+
if (idx === -1) {
239+
// Tail after the last closing marker: normalization guarantees a
240+
// `{/color}` exists, so once opened a region always finds its close —
241+
// reaching here implies `inColorRegion` is false and the tail is plain.
242+
output += rest;
243+
break;
244+
}
245+
const segment = rest.slice(0, idx);
246+
output += inColorRegion ? color(segment) : segment;
247+
rest = rest.slice(idx + marker.length);
248+
inColorRegion = !inColorRegion;
249+
}
250+
return output;
218251
}
219252

220253
/**

0 commit comments

Comments
 (0)