Skip to content
Open
Show file tree
Hide file tree
Changes from all 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