Unify ANSI rendering with sequence parser
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||||
import { ansiChunkToHtml, ansiToHtml, computeAnsiSgrState, createAnsiSgrState, hasAnsi, isAnsiSgrStateEmpty } from "../lib/ansi"
|
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||||
import { escapeHtml } from "../lib/markdown"
|
|
||||||
|
|
||||||
interface BackgroundProcessOutputDialogProps {
|
interface BackgroundProcessOutputDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,6 +17,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
let ansiRenderer = createAnsiStreamRenderer()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const process = props.process
|
const process = props.process
|
||||||
@@ -29,7 +29,6 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
let rawOutput = ""
|
let rawOutput = ""
|
||||||
let sgrState = createAnsiSgrState()
|
|
||||||
|
|
||||||
const setRawOutput = (next: string) => {
|
const setRawOutput = (next: string) => {
|
||||||
rawOutput = next
|
rawOutput = next
|
||||||
@@ -44,6 +43,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
setAnsiEnabled(false)
|
setAnsiEnabled(false)
|
||||||
setOutputHtml("")
|
setOutputHtml("")
|
||||||
setRawOutput("")
|
setRawOutput("")
|
||||||
|
ansiRenderer.reset()
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
serverApi
|
serverApi
|
||||||
@@ -57,12 +57,12 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
const detectedAnsi = hasAnsi(response.content)
|
const detectedAnsi = hasAnsi(response.content)
|
||||||
if (detectedAnsi) {
|
if (detectedAnsi) {
|
||||||
setAnsiEnabled(true)
|
setAnsiEnabled(true)
|
||||||
setOutputHtml(ansiToHtml(response.content))
|
ansiRenderer.reset()
|
||||||
sgrState = computeAnsiSgrState(response.content)
|
setOutputHtml(ansiRenderer.render(response.content))
|
||||||
} else {
|
} else {
|
||||||
setAnsiEnabled(false)
|
setAnsiEnabled(false)
|
||||||
setOutputHtml("")
|
setOutputHtml("")
|
||||||
sgrState = createAnsiSgrState()
|
ansiRenderer.reset()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -88,29 +88,20 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
const wasAnsiEnabled = ansiEnabled()
|
const wasAnsiEnabled = ansiEnabled()
|
||||||
|
|
||||||
if (!wasAnsiEnabled) {
|
if (!wasAnsiEnabled) {
|
||||||
const before = rawOutput
|
|
||||||
appendRawOutput(chunk)
|
appendRawOutput(chunk)
|
||||||
|
|
||||||
if (hasAnsi(chunk)) {
|
if (hasAnsi(chunk)) {
|
||||||
setAnsiEnabled(true)
|
setAnsiEnabled(true)
|
||||||
|
ansiRenderer.reset()
|
||||||
const initialHtml = escapeHtml(before)
|
setOutputHtml(ansiRenderer.render(rawOutput))
|
||||||
const result = ansiChunkToHtml(chunk, createAnsiSgrState())
|
|
||||||
setOutputHtml(initialHtml + result.html)
|
|
||||||
sgrState = result.nextState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
appendRawOutput(chunk)
|
appendRawOutput(chunk)
|
||||||
const result = ansiChunkToHtml(chunk, sgrState)
|
const htmlChunk = ansiRenderer.render(chunk)
|
||||||
setOutputHtml((prev) => `${prev}${result.html}`)
|
setOutputHtml((prev) => `${prev}${htmlChunk}`)
|
||||||
sgrState = result.nextState
|
|
||||||
|
|
||||||
if (isAnsiSgrStateEmpty(sgrState)) {
|
|
||||||
// keep streaming normally; state can legitimately be empty
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,41 @@
|
|||||||
import AnsiToHtml from "ansi-to-html"
|
import { createAnsiSequenceParser, createColorPalette } from "ansi-sequence-parser"
|
||||||
|
|
||||||
const ESC_CHAR = "\u001b"
|
const ESC_CHAR = "\u001b"
|
||||||
const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/
|
const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/
|
||||||
const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/
|
const ANSI_ESCAPE_PATTERN = /\u001b/
|
||||||
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 ansiConverter = new AnsiToHtml({
|
const colorPalette = createColorPalette()
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAnsi(text: string): boolean {
|
export function hasAnsi(text: string): boolean {
|
||||||
const normalized = normalizeAnsiText(text)
|
const normalized = normalizeAnsiText(text)
|
||||||
return ANSI_SGR_PATTERN.test(normalized)
|
return ANSI_ESCAPE_PATTERN.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ansiToHtml(text: string): string {
|
export function ansiToHtml(text: string): string {
|
||||||
const normalized = normalizeAnsiText(text)
|
const normalized = normalizeAnsiText(text)
|
||||||
const sanitized = stripNonSgrAnsi(normalized)
|
const parser = createAnsiSequenceParser()
|
||||||
return ansiConverter.toHtml(sanitized)
|
const tokens = parser.parse(normalized)
|
||||||
|
return tokensToHtml(tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ansiChunkToHtml(chunk: string, state: AnsiSgrState) {
|
export interface AnsiStreamRenderer {
|
||||||
const normalized = normalizeAnsiText(chunk)
|
reset: () => void
|
||||||
const sanitized = stripNonSgrAnsi(normalized)
|
render: (chunk: string) => string
|
||||||
|
|
||||||
const prefix = sgrStateToEscapeSequence(state)
|
|
||||||
const html = ansiConverter.toHtml(prefix + sanitized)
|
|
||||||
const nextState = computeAnsiSgrState(sanitized, state)
|
|
||||||
|
|
||||||
return { html, nextState }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeAnsiSgrState(text: string, initialState?: AnsiSgrState): AnsiSgrState {
|
export function createAnsiStreamRenderer(): AnsiStreamRenderer {
|
||||||
const normalized = normalizeAnsiText(text)
|
let parser = createAnsiSequenceParser()
|
||||||
const sanitized = stripNonSgrAnsi(normalized)
|
|
||||||
const nextState = cloneSgrState(initialState ?? createAnsiSgrState())
|
|
||||||
|
|
||||||
for (const match of sanitized.matchAll(ANSI_SGR_CAPTURE_PATTERN)) {
|
return {
|
||||||
const params = match[1] ?? ""
|
reset() {
|
||||||
const codes = params.length === 0 ? [0] : params.split(";").map((part) => Number(part))
|
parser = createAnsiSequenceParser()
|
||||||
applySgrCodes(nextState, codes)
|
},
|
||||||
|
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 {
|
function normalizeAnsiText(text: string): string {
|
||||||
@@ -97,177 +49,88 @@ function normalizeAnsiText(text: string): string {
|
|||||||
.replace(/\\033/g, ESC_CHAR)
|
.replace(/\\033/g, ESC_CHAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripNonSgrAnsi(text: string): string {
|
function tokensToHtml(tokens: { value: string; foreground: unknown; background: unknown; decorations: Set<string> }[]): string {
|
||||||
return text.replace(ANSI_NON_SGR_PATTERN, "")
|
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 += `<span style="${styles}">${escaped}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSgrState(state: AnsiSgrState): AnsiSgrState {
|
function buildTokenStyles(token: { foreground: any; background: any; decorations: Set<string> }): string | null {
|
||||||
return { ...state }
|
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 {
|
function escapeHtml(value: string): string {
|
||||||
if (isAnsiSgrStateEmpty(state)) {
|
return value
|
||||||
return ""
|
.replace(/&/g, "&")
|
||||||
}
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
const codes: number[] = []
|
.replace(/\"/g, """)
|
||||||
if (state.bold) codes.push(1)
|
.replace(/'/g, "'")
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user