takt/src/shared/ui/TaskPrefixWriter.ts

119 lines
3.5 KiB
TypeScript

/**
* Line-buffered, prefixed writer for task-level parallel execution.
*
* When multiple tasks run concurrently (takt run --concurrency N), each task's
* output must be identifiable and line-aligned to prevent mid-line interleaving.
* This class wraps process.stdout.write with line buffering and a colored
* `[taskName]` prefix on every non-empty line.
*
* Design mirrors ParallelLogger (movement-level) but targets task-level output:
* - Regular log lines (info, header, status) get the prefix
* - Stream output gets line-buffered then prefixed
* - Empty lines are passed through without prefix
*/
import { stripAnsi } from '../utils/text.js';
/** ANSI color codes for task prefixes (cycled by task index) */
const TASK_COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const;
const RESET = '\x1b[0m';
export interface TaskPrefixWriterOptions {
/** Task name used in the prefix */
taskName: string;
/** Color index for the prefix (cycled mod 4) */
colorIndex: number;
/** Override process.stdout.write for testing */
writeFn?: (text: string) => void;
}
export interface MovementPrefixContext {
movementName: string;
iteration: number;
maxIterations: number;
movementIteration: number;
}
/**
* Prefixed line writer for a single parallel task.
*
* All output goes through `writeLine` (complete lines) or `writeChunk`
* (buffered partial lines). The prefix `[taskName]` is prepended to every
* non-empty output line.
*/
export class TaskPrefixWriter {
private readonly taskPrefix: string;
private readonly writeFn: (text: string) => void;
private movementContext: MovementPrefixContext | undefined;
private lineBuffer = '';
constructor(options: TaskPrefixWriterOptions) {
const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length];
const taskLabel = options.taskName.slice(0, 4);
this.taskPrefix = `${color}[${taskLabel}]${RESET}`;
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
}
setMovementContext(context: MovementPrefixContext): void {
this.movementContext = context;
}
private buildPrefix(): string {
if (!this.movementContext) {
return `${this.taskPrefix} `;
}
const { movementName, iteration, maxIterations, movementIteration } = this.movementContext;
return `${this.taskPrefix}[${movementName}](${iteration}/${maxIterations})(${movementIteration}) `;
}
/**
* Write a complete line with prefix.
* Multi-line text is split and each non-empty line gets the prefix.
*/
writeLine(text: string): void {
const cleaned = stripAnsi(text);
const lines = cleaned.split('\n');
for (const line of lines) {
if (line === '') {
this.writeFn('\n');
} else {
this.writeFn(`${this.buildPrefix()}${line}\n`);
}
}
}
/**
* Write a chunk of streaming text with line buffering.
* Partial lines are buffered until a newline arrives, then output with prefix.
*/
writeChunk(text: string): void {
const cleaned = stripAnsi(text);
const combined = this.lineBuffer + cleaned;
const parts = combined.split('\n');
const remainder = parts.pop() ?? '';
this.lineBuffer = remainder;
for (const line of parts) {
if (line === '') {
this.writeFn('\n');
} else {
this.writeFn(`${this.buildPrefix()}${line}\n`);
}
}
}
/**
* Flush any remaining buffered content.
*/
flush(): void {
if (this.lineBuffer !== '') {
this.writeFn(`${this.buildPrefix()}${this.lineBuffer}\n`);
this.lineBuffer = '';
}
}
}