Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Check out documentation and other usage examples in the [`docs` directory](./doc
- `prefix`: the prefix type to use when logging processes output.
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.
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.
- `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`).
Expand Down
9 changes: 9 additions & 0 deletions lib/flow-control/logger-padding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ it('sets prefix length to the longest prefix of all commands', () => {
expect(logger.setPrefixLength).toHaveBeenCalledWith(6);
});

it('ignores color markers when measuring prefix length', () => {
logger.getPrefixContent
.mockReturnValueOnce({ type: 'template', value: '{color}foo{/color}' })
.mockReturnValueOnce({ type: 'template', value: '{color}abcd{/color}' });

controller.handle(commands);
expect(logger.setPrefixLength).toHaveBeenCalledWith(4);
});

it('does not shorten the prefix length', () => {
logger.getPrefixContent
.mockReturnValueOnce({ type: 'default', value: '100' })
Expand Down
10 changes: 7 additions & 3 deletions lib/flow-control/logger-padding.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Command } from '../command.js';
import { Logger } from '../logger.js';
import { COLOR_MARKER_RE, Logger } from '../logger.js';
import { FlowController } from './flow-controller.js';

function visibleLength(value: string | undefined): number {
return value ? value.replace(COLOR_MARKER_RE, '').length : 0;
}

export class LoggerPadding implements FlowController {
private readonly logger: Logger;

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

Expand All @@ -25,7 +29,7 @@ export class LoggerPadding implements FlowController {
command.timer.subscribe((event) => {
if (!event.endDate) {
const content = this.logger.getPrefixContent(command);
length = Math.max(length, content?.value.length || 0);
length = Math.max(length, visibleLength(content?.value));
this.logger.setPrefixLength(length);
}
}),
Expand Down
73 changes: 73 additions & 0 deletions lib/logger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,79 @@ describe('#logCommandText()', () => {
});
});

describe('#logCommandText() with color markers', () => {
it('colors only the text inside {color}...{/color} within a template prefix', () => {
const { logger } = createLogger({ prefixFormat: '[{color}{name}{/color}]' });

const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith(`[${chalk.blue('bar')}] `, 'foo', cmd);
});

it('supports multiple {color}...{/color} pairs in one template', () => {
const { logger } = createLogger({
prefixFormat: '{color}[{/color}{name}{color}]{/color}',
});

const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith(
`${chalk.blue('[')}bar${chalk.blue(']')} `,
'foo',
cmd,
);
});

it('auto-closes an unclosed {color} so the tail stays colored', () => {
const { logger } = createLogger({ prefixFormat: '[{color}{name}]' });

const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith(`[${chalk.blue('bar]')} `, 'foo', cmd);
});

it('auto-opens a bare {/color} so the head stays colored', () => {
const { logger } = createLogger({ prefixFormat: '{name}{/color}]' });

const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('bar')}] `, 'foo', cmd);
});

it('templates without markers stay fully colored (backward compat)', () => {
const { logger } = createLogger({ prefixFormat: '{name}-{index}' });

const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('bar-1')} `, 'foo', cmd);
});

it('pads templated prefix based on visible length, ignoring marker tokens', () => {
const { logger } = createLogger({ prefixFormat: '{color}{name}{/color}' });

const cmd = new FakeCommand('foo', undefined, 0, { prefixColor: 'blue' });
logger.setPrefixLength(6);
logger.logCommandText('bar', cmd);

expect(logger.log).toHaveBeenCalledWith(`${chalk.blue('foo')} `, 'bar', cmd);
});

it('strips markers and emits no ANSI escapes when colors are globally off', () => {
const { logger } = createLogger({ prefixFormat: '[{color}{name}{/color}]' });

logger.toggleColors(false);
const cmd = new FakeCommand('bar', undefined, 1, { prefixColor: 'blue' });
logger.logCommandText('foo', cmd);

expect(logger.log).toHaveBeenCalledWith('[bar] ', 'foo', cmd);
});
});

describe('#logCommandEvent()', () => {
it('does nothing if in raw mode', () => {
const { logger } = createLogger({ raw: true });
Expand Down
39 changes: 36 additions & 3 deletions lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const noColorChalk = new Chalk({ level: 0 });

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

const COLOR_OPEN = '{color}';
const COLOR_CLOSE = '{/color}';
export const COLOR_MARKER_RE = /\{\/?color\}/g;

/**
* Applies a single color segment to a chalk instance.
* Handles: function calls (hex, bgHex, rgb, bgRgb, ansi256, bgAnsi256, etc.),
Expand Down Expand Up @@ -201,9 +205,11 @@ export class Logger {
return '';
}

const visibleLength = content.value.replace(COLOR_MARKER_RE, '').length;
const padding = ' '.repeat(Math.max(0, this.prefixLength - visibleLength));
return content.type === 'template'
? content.value.padEnd(this.prefixLength, ' ')
: `[${content.value.padEnd(this.prefixLength, ' ')}]`;
? content.value + padding
: `[${content.value}${padding}]`;
}

setPrefixLength(length: number) {
Expand All @@ -214,7 +220,34 @@ export class Logger {
const prefixColor = command.prefixColor ?? '';
const defaultColor = applyColor(this.chalk, defaults.prefixColors) as ChalkInstance;
const color = applyColor(this.chalk, prefixColor) ?? defaultColor;
return color(text);

// Segment the text around `{color}` / `{/color}` markers and only apply `color`
// inside opened regions. If either marker is missing, it's implicitly added to
// the start or end respectively — so a marker-free input stays fully colored,
// preserving backward compatibility.
let normalized = text;
if (!normalized.includes(COLOR_OPEN)) normalized = COLOR_OPEN + normalized;
if (!normalized.includes(COLOR_CLOSE)) normalized = normalized + COLOR_CLOSE;

let output = '';
let rest = normalized;
let inColorRegion = false;
while (rest.length > 0) {
const marker = inColorRegion ? COLOR_CLOSE : COLOR_OPEN;
const idx = rest.indexOf(marker);
if (idx === -1) {
// Tail after the last closing marker: normalization guarantees a
// `{/color}` exists, so once opened a region always finds its close —
// reaching here implies `inColorRegion` is false and the tail is plain.
output += rest;
break;
}
const segment = rest.slice(0, idx);
output += inColorRegion ? color(segment) : segment;
rest = rest.slice(idx + marker.length);
inColorRegion = !inColorRegion;
}
return output;
}

/**
Expand Down
Loading