cflx 0.6.11

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
import React, { useMemo } from 'react';
import { AnsiUp } from 'ansi-up';
import { RemoteLogEntry } from '../api/types';

interface LogEntryProps {
  entry: RemoteLogEntry;
  showProjectLabel?: boolean;
}

const levelConfig: Record<string, { label: string; color: string; bg: string }> = {
  info: { label: 'INFO', color: 'text-[#3b82f6]', bg: '' },
  warn: { label: 'WARN', color: 'text-[#f59e0b]', bg: 'bg-[#451a03]/20' },
  error: { label: 'ERR ', color: 'text-[#ef4444]', bg: 'bg-[#450a0a]/20' },
};

// Module-level singleton: AnsiUp is stateful (tracks incomplete sequences
// across calls), but we create one instance per component render via useMemo
// to avoid cross-entry state leakage.

/**
 * Convert ANSI escape sequences to safe HTML.
 *
 * Uses ansi-up for SGR color codes and adds lightweight handling for
 * bold (SGR 1) and underline (SGR 4) which ansi-up maps to bright
 * colors only.
 */
export function ansiToHtml(raw: string): string {
  const ansiUp = new AnsiUp();
  ansiUp.useClasses = true;
  // escapeForHtml is true by default – HTML special chars are escaped
  // before ANSI processing, preventing XSS.

  // --- Pre-process: extract bold/underline state ---
  // ansi-up treats SGR 1 as "bright" (color shift) and ignores SGR 4.
  // We strip these codes before ansi-up and re-apply them as wrapper
  // spans afterwards.  Because ansi-up escapes HTML first, the Unicode
  // PUA markers we inject cannot collide with user content and will
  // survive the conversion unchanged.
  const BOLD_OPEN = '\uE000';
  const BOLD_CLOSE = '\uE001';
  const UNDERLINE_OPEN = '\uE002';
  const UNDERLINE_CLOSE = '\uE003';

  let text = raw;

  // Replace SGR 1 (bold on) / 22 (bold off) with markers
  text = text.replace(/\x1b\[1m/g, BOLD_OPEN);
  text = text.replace(/\x1b\[22m/g, BOLD_CLOSE);

  // Replace SGR 4 (underline on) / 24 (underline off) with markers
  text = text.replace(/\x1b\[4m/g, UNDERLINE_OPEN);
  text = text.replace(/\x1b\[24m/g, UNDERLINE_CLOSE);

  // SGR 0 (reset all) should also close bold/underline.
  // We insert close markers before every reset so open tags get closed.
  text = text.replace(/\x1b\[0m/g, `${BOLD_CLOSE}${UNDERLINE_CLOSE}\x1b[0m`);

  // --- ansi-up conversion ---
  let html = ansiUp.ansi_to_html(text);

  // --- Post-process: replace markers with styled spans ---
  html = html
    .replace(new RegExp(BOLD_OPEN, 'g'), '<span class="ansi-bold">')
    .replace(new RegExp(BOLD_CLOSE, 'g'), '</span>')
    .replace(new RegExp(UNDERLINE_OPEN, 'g'), '<span class="ansi-underline">')
    .replace(new RegExp(UNDERLINE_CLOSE, 'g'), '</span>');

  return html;
}

export function LogEntry({ entry, showProjectLabel = false }: LogEntryProps) {
  const date = new Date(entry.timestamp);
  const timeStr = date.toLocaleTimeString('en', { hour12: false });
  const cfg = levelConfig[entry.level] ?? levelConfig.info;

  const messageHtml = useMemo(() => ansiToHtml(entry.message), [entry.message]);

  return (
    <div className={`flex gap-2 rounded px-2 py-1 font-mono text-xs ${cfg.bg}`}>
      <span className="shrink-0 text-[#3f3f46]">{timeStr}</span>
      <span className={`shrink-0 ${cfg.color}`}>{cfg.label}</span>
      {showProjectLabel && entry.project_id ? (
        <span className="shrink-0 rounded bg-[#18181b] px-1.5 py-0.5 text-[#71717a]">
          {entry.project_id}
        </span>
      ) : null}
      <span
        className="text-[#a1a1aa] break-all ansi-log-message"
        dangerouslySetInnerHTML={{ __html: messageHtml }}
      />
    </div>
  );
}