diff --git a/README.md b/README.md index 6ce460ca..893c8b5a 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/lib/flow-control/logger-padding.spec.ts b/lib/flow-control/logger-padding.spec.ts index 502889d0..dfe5eb0a 100644 --- a/lib/flow-control/logger-padding.spec.ts +++ b/lib/flow-control/logger-padding.spec.ts @@ -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' }) diff --git a/lib/flow-control/logger-padding.ts b/lib/flow-control/logger-padding.ts index 1b837c8f..14a1bf13 100644 --- a/lib/flow-control/logger-padding.ts +++ b/lib/flow-control/logger-padding.ts @@ -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; @@ -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); @@ -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); } }), diff --git a/lib/logger.spec.ts b/lib/logger.spec.ts index 1c287943..72087489 100644 --- a/lib/logger.spec.ts +++ b/lib/logger.spec.ts @@ -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 }); diff --git a/lib/logger.ts b/lib/logger.ts index 81beb8fc..47093af3 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -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.), @@ -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) { @@ -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; } /**