From 33d21777d3be12063694d2596f8df6b8dd8e41f5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Wed, 18 Mar 2026 10:55:31 -0700 Subject: [PATCH] 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 --- src/client/ansi.ts | 62 +++++++++++++++++++++++++++ src/client/components/LogsSection.tsx | 5 ++- src/client/components/UnifiedLogs.tsx | 8 ++-- 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 src/client/ansi.ts diff --git a/src/client/ansi.ts b/src/client/ansi.ts new file mode 100644 index 0000000..16cd941 --- /dev/null +++ b/src/client/ansi.ts @@ -0,0 +1,62 @@ +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 escape = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + +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 += ''; open = false } + } else if (COLORS[code]) { + if (open) result += '' + result += `` + open = true + } else if (code === 1) { + if (open) result += '' + result += '' + open = true + } else if (code === 2) { + if (open) result += '' + result += '' + open = true + } + } + } + + result += escape(text.slice(last)) + if (open) result += '' + + return result +} diff --git a/src/client/components/LogsSection.tsx b/src/client/components/LogsSection.tsx index e7ffda4..effac3d 100644 --- a/src/client/components/LogsSection.tsx +++ b/src/client/components/LogsSection.tsx @@ -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) => ( {new Date(line.time).toLocaleTimeString()} - {line.text} + )) ) : ( @@ -126,7 +127,7 @@ function LogsContent() { {line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'} - {line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')} + )) ) : ( diff --git a/src/client/components/UnifiedLogs.tsx b/src/client/components/UnifiedLogs.tsx index cd4a070..930147b 100644 --- a/src/client/components/UnifiedLogs.tsx +++ b/src/client/components/UnifiedLogs.tsx @@ -1,3 +1,4 @@ +import { ansiToHtml } from '../ansi' import { isNarrow } from '../state' import { LogApp, @@ -65,9 +66,10 @@ function LogLineEntry({ log }: { log: UnifiedLogLine }) { {formatTime(log.time)} {log.app} - - {log.text} - + ) }