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> = { const ESC = /\x1b\[([0-9;]*)m/g
30: '#666', // black (brightened for dark bg)
31: '#f87171', // red const STYLES: Record<number, string> = {
32: '#4ade80', // green 1: 'font-weight:bold',
33: '#facc15', // yellow 2: 'opacity:0.7',
34: '#60a5fa', // blue 30: 'color:#666',
35: '#c084fc', // magenta 31: 'color:#f87171',
36: '#22d3ee', // cyan 32: 'color:#4ade80',
37: '#e5e5e5', // white 33: 'color:#facc15',
90: '#999', // bright black 34: 'color:#60a5fa',
91: '#fca5a5', // bright red 35: 'color:#c084fc',
92: '#86efac', // bright green 36: 'color:#22d3ee',
93: '#fde047', // bright yellow 37: 'color:#e5e5e5',
94: '#93c5fd', // bright blue 90: 'color:#999',
95: '#d8b4fe', // bright magenta 91: 'color:#fca5a5',
96: '#67e8f9', // bright cyan 92: 'color:#86efac',
97: '#fff', // bright white 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 { export function ansiToHtml(text: string): string {
if (!text.includes('\x1b')) return escape(text) if (!text.includes('\x1b')) return escape(text)
const ESC = /\x1b\[([0-9;]*)m/g ESC.lastIndex = 0
let result = '' let result = ''
let last = 0 let last = 0
let open = false let open = false
@ -34,15 +44,16 @@ export function ansiToHtml(text: string): string {
const styles: string[] = [] const styles: string[] = []
for (const code of codes) { for (const code of codes) {
if (code === 0 || code === 39) { if (code === 0) {
styles.length = 0 styles.length = 0
if (open) { result += '</span>'; open = false } if (open) { result += '</span>'; open = false }
} else if (COLORS[code]) { } else if (code === 39) {
styles.push(`color:${COLORS[code]}`) const filtered = styles.filter(s => !s.startsWith('color:'))
} else if (code === 1) { styles.length = 0
styles.push('font-weight:bold') styles.push(...filtered)
} else if (code === 2) { if (open) { result += '</span>'; open = false }
styles.push('opacity:0.7') } else if (STYLES[code]) {
styles.push(STYLES[code])
} }
} }
@ -58,6 +69,3 @@ export function ansiToHtml(text: string): string {
return result 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 { isNarrow } from '../state'
import { import {
LogApp, LogApp,
@ -59,7 +59,7 @@ function parseLogText(text: string): { method?: string, path?: string, status?:
} }
function LogLineEntry({ log }: { log: UnifiedLogLine }) { function LogLineEntry({ log }: { log: UnifiedLogLine }) {
const parsed = parseLogText(log.text) const parsed = parseLogText(stripAnsi(log.text))
const statusColor = getStatusColor(parsed.status) const statusColor = getStatusColor(parsed.status)
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
return ( return (