Add ANSI color code to HTML conversion for log display

Terminal color codes were rendering as raw escape sequences in the
web UI, making logs hard to read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-18 10:55:31 -07:00
parent 62f936cdef
commit 33d21777d3
3 changed files with 70 additions and 5 deletions

62
src/client/ansi.ts Normal file
View File

@ -0,0 +1,62 @@
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 escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
export function ansiToHtml(text: string): string {
if (!text.includes('\x1b')) return escape(text)
let result = ''
let last = 0
let open = false
let match: RegExpExecArray | null
while ((match = ESC.exec(text)) !== null) {
result += escape(text.slice(last, match.index))
last = match.index + match[0].length
const codes = match[1] ? match[1].split(';').map(Number) : [0]
for (const code of codes) {
if (code === 0 || code === 39) {
if (open) { result += '</span>'; open = false }
} else if (COLORS[code]) {
if (open) result += '</span>'
result += `<span style="color:${COLORS[code]}">`
open = true
} else if (code === 1) {
if (open) result += '</span>'
result += '<span style="font-weight:bold">'
open = true
} else if (code === 2) {
if (open) result += '</span>'
result += '<span style="opacity:0.7">'
open = true
}
}
}
result += escape(text.slice(last))
if (open) result += '</span>'
return result
}

View File

@ -1,5 +1,6 @@
import { define } from '@because/forge'
import type { App, LogLine as LogLineType } from '../../shared/types'
import { ansiToHtml } from '../ansi'
import { getLogDates, getLogsForDate } from '../api'
import { isNarrow } from '../state'
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
@ -107,7 +108,7 @@ function LogsContent() {
filteredLiveLogs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.text) }} />
</LogLine>
))
) : (
@ -126,7 +127,7 @@ function LogsContent() {
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
</span>
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')) }} />
</LogLine>
))
) : (

View File

@ -1,3 +1,4 @@
import { ansiToHtml } from '../ansi'
import { isNarrow } from '../state'
import {
LogApp,
@ -65,9 +66,10 @@ function LogLineEntry({ log }: { log: UnifiedLogLine }) {
<LogEntry narrow={narrow}>
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
<LogApp narrow={narrow}>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
<LogText
style={statusColor ? { color: statusColor } : undefined}
dangerouslySetInnerHTML={{ __html: ansiToHtml(log.text) }}
/>
</LogEntry>
)
}