From a824d62058955db6e4e3233b151bb8a480635173 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 18 Mar 2026 11:14:03 -0700 Subject: [PATCH] Refactor ANSI parser to support SGR reset (code 39) and bold/dim styles Merge color and style maps into a unified STYLES table, hoist the regex to module scope, export stripAnsi for use in log parsing, and handle SGR 39 (default foreground) by removing only color styles instead of clearing all styles. Co-Authored-By: Claude Opus 4.6 --- src/client/ansi.ts | 64 +++++++++++++++------------ src/client/components/UnifiedLogs.tsx | 4 +- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/client/ansi.ts b/src/client/ansi.ts index bae5c38..8a770c2 100644 --- a/src/client/ansi.ts +++ b/src/client/ansi.ts @@ -1,26 +1,36 @@ -const COLORS: Record = { - 30: '#666', // black (brightened for dark bg) - 31: '#f87171', // red - 32: '#4ade80', // green - 33: '#facc15', // yellow - 34: '#60a5fa', // blue - 35: '#c084fc', // magenta - 36: '#22d3ee', // cyan - 37: '#e5e5e5', // white - 90: '#999', // bright black - 91: '#fca5a5', // bright red - 92: '#86efac', // bright green - 93: '#fde047', // bright yellow - 94: '#93c5fd', // bright blue - 95: '#d8b4fe', // bright magenta - 96: '#67e8f9', // bright cyan - 97: '#fff', // bright white +const ESC = /\x1b\[([0-9;]*)m/g + +const STYLES: Record = { + 1: 'font-weight:bold', + 2: 'opacity:0.7', + 30: 'color:#666', + 31: 'color:#f87171', + 32: 'color:#4ade80', + 33: 'color:#facc15', + 34: 'color:#60a5fa', + 35: 'color:#c084fc', + 36: 'color:#22d3ee', + 37: 'color:#e5e5e5', + 90: 'color:#999', + 91: 'color:#fca5a5', + 92: 'color:#86efac', + 93: 'color:#fde047', + 94: 'color:#93c5fd', + 95: 'color:#d8b4fe', + 96: 'color:#67e8f9', + 97: 'color:#fff', } +const escape = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + +export const stripAnsi = (s: string) => + s.replace(/\x1b\[[0-9;]*m/g, '') + export function ansiToHtml(text: string): string { if (!text.includes('\x1b')) return escape(text) - const ESC = /\x1b\[([0-9;]*)m/g + ESC.lastIndex = 0 let result = '' let last = 0 let open = false @@ -34,15 +44,16 @@ export function ansiToHtml(text: string): string { const styles: string[] = [] for (const code of codes) { - if (code === 0 || code === 39) { + if (code === 0) { styles.length = 0 if (open) { result += ''; open = false } - } else if (COLORS[code]) { - styles.push(`color:${COLORS[code]}`) - } else if (code === 1) { - styles.push('font-weight:bold') - } else if (code === 2) { - styles.push('opacity:0.7') + } else if (code === 39) { + const filtered = styles.filter(s => !s.startsWith('color:')) + styles.length = 0 + styles.push(...filtered) + if (open) { result += ''; open = false } + } else if (STYLES[code]) { + styles.push(STYLES[code]) } } @@ -58,6 +69,3 @@ export function ansiToHtml(text: string): string { return result } - -const escape = (s: string) => - s.replace(/&/g, '&').replace(//g, '>') diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx index 930147b..be4b052 100644 --- a/src/client/components/UnifiedLogs.tsx +++ b/src/client/components/UnifiedLogs.tsx @@ -1,4 +1,4 @@ -import { ansiToHtml } from '../ansi' +import { ansiToHtml, stripAnsi } from '../ansi' import { isNarrow } from '../state' import { LogApp, @@ -59,7 +59,7 @@ function parseLogText(text: string): { method?: string, path?: string, status?: } function LogLineEntry({ log }: { log: UnifiedLogLine }) { - const parsed = parseLogText(log.text) + const parsed = parseLogText(stripAnsi(log.text)) const statusColor = getStatusColor(parsed.status) const narrow = isNarrow || undefined return (