diff --git a/packages/ui/src/components/background-process-output-dialog.tsx b/packages/ui/src/components/background-process-output-dialog.tsx
index be7cfd6b..d3895621 100644
--- a/packages/ui/src/components/background-process-output-dialog.tsx
+++ b/packages/ui/src/components/background-process-output-dialog.tsx
@@ -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
Output truncated for display.
-
- {output()}
-
+
+ {output()}
+
+ }
+ >
+
+
diff --git a/packages/ui/src/lib/ansi.ts b/packages/ui/src/lib/ansi.ts
index b4b52211..c62d853c 100644
--- a/packages/ui/src/lib/ansi.ts
+++ b/packages/ui/src/lib/ansi.ts
@@ -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
+ }
+ }
+ }
+}