picoclaw/web/frontend/src/lib/ansi-log.ts

290 lines
5.8 KiB
TypeScript

import type { CSSProperties } from "react"
import wrapAnsi from "wrap-ansi"
export type AnsiSegment = {
style: CSSProperties
text: string
}
type AnsiState = {
background?: string
bold?: boolean
dim?: boolean
foreground?: string
italic?: boolean
strikethrough?: boolean
underline?: boolean
underlineColor?: string
}
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[([0-9;]*)m`, "g")
const ANSI_COLORS = [
"#4b5563",
"#f87171",
"#4ade80",
"#facc15",
"#60a5fa",
"#c084fc",
"#22d3ee",
"#f3f4f6",
]
const ANSI_BRIGHT_COLORS = [
"#6b7280",
"#fb7185",
"#86efac",
"#fde047",
"#93c5fd",
"#e879f9",
"#67e8f9",
"#ffffff",
]
function cloneAnsiState(state: AnsiState): AnsiState {
return { ...state }
}
function ansi256ToHex(code: number): string {
if (code < 0 || code > 255) {
return "inherit"
}
if (code < 8) {
return ANSI_COLORS[code]
}
if (code < 16) {
return ANSI_BRIGHT_COLORS[code - 8]
}
if (code < 232) {
const index = code - 16
const red = Math.floor(index / 36)
const green = Math.floor((index % 36) / 6)
const blue = index % 6
const scale = [0, 95, 135, 175, 215, 255]
return `rgb(${scale[red]}, ${scale[green]}, ${scale[blue]})`
}
const gray = 8 + (code - 232) * 10
return `rgb(${gray}, ${gray}, ${gray})`
}
function codeToColor(code: number): string | undefined {
if (code >= 30 && code <= 37) {
return ANSI_COLORS[code - 30]
}
if (code >= 40 && code <= 47) {
return ANSI_COLORS[code - 40]
}
if (code >= 90 && code <= 97) {
return ANSI_BRIGHT_COLORS[code - 90]
}
if (code >= 100 && code <= 107) {
return ANSI_BRIGHT_COLORS[code - 100]
}
if (code === 39 || code === 49) {
return undefined
}
}
function applyExtendedColor(
state: AnsiState,
codes: number[],
index: number,
target: "foreground" | "background" | "underlineColor",
): number {
const mode = codes[index + 1]
if (mode === 5) {
const colorCode = codes[index + 2]
if (colorCode !== undefined) {
state[target] = ansi256ToHex(colorCode)
return index + 2
}
}
if (mode === 2) {
const red = codes[index + 2]
const green = codes[index + 3]
const blue = codes[index + 4]
if (red !== undefined && green !== undefined && blue !== undefined) {
state[target] = `rgb(${red}, ${green}, ${blue})`
return index + 4
}
}
return index
}
function styleToCss(style: AnsiState): CSSProperties {
return {
backgroundColor: style.background,
color: style.foreground,
fontStyle: style.italic ? "italic" : undefined,
fontWeight: style.bold ? 700 : undefined,
opacity: style.dim ? 0.7 : undefined,
textDecorationColor: style.underlineColor,
textDecorationLine:
[
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}
}
export function parseAnsiSegments(input: string): AnsiSegment[] {
const segments: AnsiSegment[] = []
const state: AnsiState = {}
let lastIndex = 0
let match: RegExpExecArray | null
const pushText = (text: string) => {
if (!text) {
return
}
segments.push({
style: styleToCss(cloneAnsiState(state)),
text,
})
}
ANSI_PATTERN.lastIndex = 0
while ((match = ANSI_PATTERN.exec(input)) !== null) {
pushText(input.slice(lastIndex, match.index))
const codes = (match[1] || "0")
.split(";")
.map((value) => (value === "" ? 0 : Number.parseInt(value, 10)))
.filter((value) => Number.isFinite(value))
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index]
if (code === 0) {
Object.keys(state).forEach((key) => {
delete state[key as keyof AnsiState]
})
continue
}
if (code === 1) {
state.bold = true
continue
}
if (code === 2) {
state.dim = true
continue
}
if (code === 3) {
state.italic = true
continue
}
if (code === 4) {
state.underline = true
continue
}
if (code === 9) {
state.strikethrough = true
continue
}
if (code === 21 || code === 22) {
delete state.bold
delete state.dim
continue
}
if (code === 23) {
delete state.italic
continue
}
if (code === 24) {
delete state.underline
continue
}
if (code === 29) {
delete state.strikethrough
continue
}
if (code === 39) {
delete state.foreground
continue
}
if (code === 49) {
delete state.background
continue
}
if (code === 59) {
delete state.underlineColor
continue
}
if (code === 38) {
index = applyExtendedColor(state, codes, index, "foreground")
continue
}
if (code === 48) {
index = applyExtendedColor(state, codes, index, "background")
continue
}
if (code === 58) {
index = applyExtendedColor(state, codes, index, "underlineColor")
continue
}
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
state.foreground = codeToColor(code)
continue
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
state.background = codeToColor(code)
}
}
lastIndex = ANSI_PATTERN.lastIndex
}
pushText(input.slice(lastIndex))
if (segments.length === 0) {
return [{ style: {}, text: input }]
}
return segments
}
export function wrapLogLine(line: string, columns: number): string {
const normalized = line.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (columns < 20) {
return normalized
}
return wrapAnsi(normalized, columns, {
hard: true,
trim: false,
wordWrap: false,
})
}