diff --git a/packages/ui/package.json b/packages/ui/package.json index be5177ed..e31c3392 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,7 +17,7 @@ "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", "@suid/system": "^0.14.0", - "ansi-to-html": "^0.7.2", + "ansi-sequence-parser": "^1.1.3", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", diff --git a/packages/ui/src/components/background-process-output-dialog.tsx b/packages/ui/src/components/background-process-output-dialog.tsx index d3895621..ee412e53 100644 --- a/packages/ui/src/components/background-process-output-dialog.tsx +++ b/packages/ui/src/components/background-process-output-dialog.tsx @@ -2,8 +2,7 @@ import { Dialog } from "@kobalte/core/dialog" import { Show, createEffect, createSignal, onCleanup } from "solid-js" import type { BackgroundProcess } from "../../../server/src/api-types" import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client" -import { ansiChunkToHtml, ansiToHtml, computeAnsiSgrState, createAnsiSgrState, hasAnsi, isAnsiSgrStateEmpty } from "../lib/ansi" -import { escapeHtml } from "../lib/markdown" +import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi" interface BackgroundProcessOutputDialogProps { open: boolean @@ -18,6 +17,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial const [ansiEnabled, setAnsiEnabled] = createSignal(false) const [truncated, setTruncated] = createSignal(false) const [loading, setLoading] = createSignal(false) + let ansiRenderer = createAnsiStreamRenderer() createEffect(() => { const process = props.process @@ -29,7 +29,6 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial let active = true let rawOutput = "" - let sgrState = createAnsiSgrState() const setRawOutput = (next: string) => { rawOutput = next @@ -44,6 +43,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial setAnsiEnabled(false) setOutputHtml("") setRawOutput("") + ansiRenderer.reset() setLoading(true) serverApi @@ -57,12 +57,12 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial const detectedAnsi = hasAnsi(response.content) if (detectedAnsi) { setAnsiEnabled(true) - setOutputHtml(ansiToHtml(response.content)) - sgrState = computeAnsiSgrState(response.content) + ansiRenderer.reset() + setOutputHtml(ansiRenderer.render(response.content)) } else { setAnsiEnabled(false) setOutputHtml("") - sgrState = createAnsiSgrState() + ansiRenderer.reset() } }) .catch(() => { @@ -88,29 +88,20 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial const wasAnsiEnabled = ansiEnabled() if (!wasAnsiEnabled) { - const before = rawOutput appendRawOutput(chunk) if (hasAnsi(chunk)) { setAnsiEnabled(true) - - const initialHtml = escapeHtml(before) - const result = ansiChunkToHtml(chunk, createAnsiSgrState()) - setOutputHtml(initialHtml + result.html) - sgrState = result.nextState + ansiRenderer.reset() + setOutputHtml(ansiRenderer.render(rawOutput)) } return } appendRawOutput(chunk) - const result = ansiChunkToHtml(chunk, sgrState) - setOutputHtml((prev) => `${prev}${result.html}`) - sgrState = result.nextState - - if (isAnsiSgrStateEmpty(sgrState)) { - // keep streaming normally; state can legitimately be empty - } + const htmlChunk = ansiRenderer.render(chunk) + setOutputHtml((prev) => `${prev}${htmlChunk}`) } catch { // ignore parse errors } diff --git a/packages/ui/src/lib/ansi.ts b/packages/ui/src/lib/ansi.ts index c62d853c..cd1f829e 100644 --- a/packages/ui/src/lib/ansi.ts +++ b/packages/ui/src/lib/ansi.ts @@ -1,89 +1,41 @@ -import AnsiToHtml from "ansi-to-html" +import { createAnsiSequenceParser, createColorPalette } from "ansi-sequence-parser" const ESC_CHAR = "\u001b" const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/ -const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/ -const ANSI_NON_SGR_PATTERN = /\u001b\[[0-9;?]*[A-Za-ln-zA-LN-Z]/g -const ANSI_SGR_CAPTURE_PATTERN = /\u001b\[([0-9;]*)m/g +const ANSI_ESCAPE_PATTERN = /\u001b/ -const ansiConverter = new AnsiToHtml({ - escapeXML: true, -}) - -export interface AnsiSgrState { - bold: boolean - dim: boolean - italic: boolean - underline: boolean - inverse: boolean - hidden: boolean - strike: boolean - fg: string | null - bg: string | null -} - -export function createAnsiSgrState(): AnsiSgrState { - return { - bold: false, - dim: false, - italic: false, - underline: false, - inverse: false, - hidden: false, - strike: false, - fg: null, - bg: null, - } -} +const colorPalette = createColorPalette() export function hasAnsi(text: string): boolean { const normalized = normalizeAnsiText(text) - return ANSI_SGR_PATTERN.test(normalized) + return ANSI_ESCAPE_PATTERN.test(normalized) } export function ansiToHtml(text: string): string { const normalized = normalizeAnsiText(text) - const sanitized = stripNonSgrAnsi(normalized) - return ansiConverter.toHtml(sanitized) + const parser = createAnsiSequenceParser() + const tokens = parser.parse(normalized) + return tokensToHtml(tokens) } -export function ansiChunkToHtml(chunk: string, state: AnsiSgrState) { - const normalized = normalizeAnsiText(chunk) - const sanitized = stripNonSgrAnsi(normalized) - - const prefix = sgrStateToEscapeSequence(state) - const html = ansiConverter.toHtml(prefix + sanitized) - const nextState = computeAnsiSgrState(sanitized, state) - - return { html, nextState } +export interface AnsiStreamRenderer { + reset: () => void + render: (chunk: string) => string } -export function computeAnsiSgrState(text: string, initialState?: AnsiSgrState): AnsiSgrState { - const normalized = normalizeAnsiText(text) - const sanitized = stripNonSgrAnsi(normalized) - const nextState = cloneSgrState(initialState ?? createAnsiSgrState()) +export function createAnsiStreamRenderer(): AnsiStreamRenderer { + let parser = createAnsiSequenceParser() - for (const match of sanitized.matchAll(ANSI_SGR_CAPTURE_PATTERN)) { - const params = match[1] ?? "" - const codes = params.length === 0 ? [0] : params.split(";").map((part) => Number(part)) - applySgrCodes(nextState, codes) + return { + reset() { + parser = createAnsiSequenceParser() + }, + render(chunk: string) { + const normalized = normalizeAnsiText(chunk) + const tokens = parser.parse(normalized) + return tokensToHtml(tokens) + }, } - - return nextState -} - -export function isAnsiSgrStateEmpty(state: AnsiSgrState): boolean { - return ( - !state.bold && - !state.dim && - !state.italic && - !state.underline && - !state.inverse && - !state.hidden && - !state.strike && - state.fg === null && - state.bg === null - ) } function normalizeAnsiText(text: string): string { @@ -97,177 +49,88 @@ function normalizeAnsiText(text: string): string { .replace(/\\033/g, ESC_CHAR) } -function stripNonSgrAnsi(text: string): string { - return text.replace(ANSI_NON_SGR_PATTERN, "") +function tokensToHtml(tokens: { value: string; foreground: unknown; background: unknown; decorations: Set }[]): string { + let html = "" + + for (const token of tokens) { + if (!token.value) { + continue + } + + const styles = buildTokenStyles(token) + const escaped = escapeHtml(token.value) + + if (!styles) { + html += escaped + continue + } + + html += `${escaped}` + } + + return html } -function cloneSgrState(state: AnsiSgrState): AnsiSgrState { - return { ...state } +function buildTokenStyles(token: { foreground: any; background: any; decorations: Set }): string | null { + const decorations = token.decorations + let foreground = token.foreground ? colorPalette.value(token.foreground) : null + let background = token.background ? colorPalette.value(token.background) : null + + if (decorations.has("reverse")) { + const swapped = foreground + foreground = background + background = swapped + } + + const styles: string[] = [] + + if (foreground) { + styles.push(`color: ${foreground}`) + } + + if (background) { + styles.push(`background-color: ${background}`) + } + + if (decorations.has("bold")) { + styles.push("font-weight: 600") + } + + if (decorations.has("dim")) { + styles.push("opacity: 0.7") + } + + if (decorations.has("italic")) { + styles.push("font-style: italic") + } + + const lines: string[] = [] + if (decorations.has("underline")) { + lines.push("underline") + } + if (decorations.has("strikethrough")) { + lines.push("line-through") + } + if (decorations.has("overline")) { + lines.push("overline") + } + if (lines.length > 0) { + styles.push(`text-decoration-line: ${lines.join(" ")}`) + } + + if (decorations.has("hidden")) { + styles.push("color: transparent") + styles.push("background-color: transparent") + } + + return styles.length > 0 ? styles.join("; ") : null } -function sgrStateToEscapeSequence(state: AnsiSgrState): string { - if (isAnsiSgrStateEmpty(state)) { - return "" - } - - const codes: number[] = [] - if (state.bold) codes.push(1) - if (state.dim) codes.push(2) - if (state.italic) codes.push(3) - if (state.underline) codes.push(4) - if (state.inverse) codes.push(7) - if (state.hidden) codes.push(8) - if (state.strike) codes.push(9) - - if (state.fg) { - codes.push(...state.fg.split(";").map((part) => Number(part))) - } - - if (state.bg) { - codes.push(...state.bg.split(";").map((part) => Number(part))) - } - - if (codes.length === 0) { - return "" - } - - return `${ESC_CHAR}[${codes.join(";")}m` -} - -function applySgrCodes(state: AnsiSgrState, codes: number[]) { - for (let index = 0; index < codes.length; index++) { - const code = codes[index] - if (!Number.isFinite(code)) continue - - if (code === 0) { - state.bold = false - state.dim = false - state.italic = false - state.underline = false - state.inverse = false - state.hidden = false - state.strike = false - state.fg = null - state.bg = null - continue - } - - if (code === 1) { - state.bold = true - continue - } - - if (code === 2) { - state.dim = true - continue - } - - if (code === 22) { - state.bold = false - state.dim = false - continue - } - - if (code === 3) { - state.italic = true - continue - } - - if (code === 23) { - state.italic = false - continue - } - - if (code === 4) { - state.underline = true - continue - } - - if (code === 24) { - state.underline = false - continue - } - - if (code === 7) { - state.inverse = true - continue - } - - if (code === 27) { - state.inverse = false - continue - } - - if (code === 8) { - state.hidden = true - continue - } - - if (code === 28) { - state.hidden = false - continue - } - - if (code === 9) { - state.strike = true - continue - } - - if (code === 29) { - state.strike = false - continue - } - - if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { - state.fg = String(code) - continue - } - - if (code === 39) { - state.fg = null - continue - } - - if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { - state.bg = String(code) - continue - } - - if (code === 49) { - state.bg = null - continue - } - - if (code === 38 || code === 48) { - const isForeground = code === 38 - const mode = codes[index + 1] - if (!Number.isFinite(mode)) { - continue - } - - if (mode === 5) { - const color = codes[index + 2] - if (Number.isFinite(color)) { - const value = `${code};5;${color}` - if (isForeground) state.fg = value - else state.bg = value - } - index += 2 - continue - } - - if (mode === 2) { - const r = codes[index + 2] - const g = codes[index + 3] - const b = codes[index + 4] - if ([r, g, b].every((v) => Number.isFinite(v))) { - const value = `${code};2;${r};${g};${b}` - if (isForeground) state.fg = value - else state.bg = value - } - index += 4 - continue - } - } - } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") }