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 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-18 11:14:03 -07:00
parent 33d91747af
commit a824d62058
2 changed files with 38 additions and 30 deletions

View File

@ -1,26 +1,36 @@
const COLORS: Record<number, string> = {
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<number, string> = {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 += '</span>'; 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 += '</span>'; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

View File

@ -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 (