Render ANSI background output

This commit is contained in:
Shantur Rathore
2026-01-02 19:26:02 +00:00
parent a041e1c6c3
commit 4571a1dcf9
2 changed files with 317 additions and 7 deletions

View File

@@ -2,6 +2,8 @@ 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"
interface BackgroundProcessOutputDialogProps {
open: boolean
@@ -12,6 +14,8 @@ interface BackgroundProcessOutputDialogProps {
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
const [truncated, setTruncated] = createSignal(false)
const [loading, setLoading] = createSignal(false)
@@ -24,17 +28,48 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
let eventSource: EventSource | null = null
let active = true
let rawOutput = ""
let sgrState = createAnsiSgrState()
const setRawOutput = (next: string) => {
rawOutput = next
setOutput(next)
}
const appendRawOutput = (chunk: string) => {
rawOutput += chunk
setOutput(rawOutput)
}
setAnsiEnabled(false)
setOutputHtml("")
setRawOutput("")
setLoading(true)
serverApi
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
.then((response) => {
if (!active) return
setOutput(response.content)
setRawOutput(response.content)
setTruncated(response.truncated)
const detectedAnsi = hasAnsi(response.content)
if (detectedAnsi) {
setAnsiEnabled(true)
setOutputHtml(ansiToHtml(response.content))
sgrState = computeAnsiSgrState(response.content)
} else {
setAnsiEnabled(false)
setOutputHtml("")
sgrState = createAnsiSgrState()
}
})
.catch(() => {
if (!active) return
setOutput("Failed to load output.")
setRawOutput("Failed to load output.")
setAnsiEnabled(false)
setOutputHtml("")
})
.finally(() => {
if (!active) return
@@ -45,8 +80,36 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }
if (payload?.type === "chunk" && typeof payload.content === "string") {
setOutput((prev) => `${prev}${payload.content}`)
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
return
}
const chunk = payload.content
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
}
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
}
} catch {
// ignore parse errors
@@ -90,9 +153,19 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
</Show>
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
<Show
when={ansiEnabled()}
fallback={
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
}
>
<pre
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
innerHTML={outputHtml()}
/>
</Show>
</Show>
</div>
</Dialog.Content>

View File

@@ -4,11 +4,38 @@ 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 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,
}
}
export function hasAnsi(text: string): boolean {
const normalized = normalizeAnsiText(text)
return ANSI_SGR_PATTERN.test(normalized)
@@ -20,6 +47,45 @@ export function ansiToHtml(text: string): string {
return ansiConverter.toHtml(sanitized)
}
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 function computeAnsiSgrState(text: string, initialState?: AnsiSgrState): AnsiSgrState {
const normalized = normalizeAnsiText(text)
const sanitized = stripNonSgrAnsi(normalized)
const nextState = cloneSgrState(initialState ?? createAnsiSgrState())
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 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 {
if (!ANSI_LITERAL_PATTERN.test(text)) {
return text
@@ -34,3 +100,174 @@ function normalizeAnsiText(text: string): string {
function stripNonSgrAnsi(text: string): string {
return text.replace(ANSI_NON_SGR_PATTERN, "")
}
function cloneSgrState(state: AnsiSgrState): AnsiSgrState {
return { ...state }
}
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
}
}
}
}