Render ANSI background output
This commit is contained in:
@@ -2,6 +2,8 @@ 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 { escapeHtml } from "../lib/markdown"
|
||||||
|
|
||||||
interface BackgroundProcessOutputDialogProps {
|
interface BackgroundProcessOutputDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +14,8 @@ interface BackgroundProcessOutputDialogProps {
|
|||||||
|
|
||||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||||
const [output, setOutput] = createSignal("")
|
const [output, setOutput] = createSignal("")
|
||||||
|
const [outputHtml, setOutputHtml] = createSignal("")
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -24,17 +28,48 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
let active = true
|
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)
|
setLoading(true)
|
||||||
serverApi
|
serverApi
|
||||||
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
|
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setOutput(response.content)
|
|
||||||
|
setRawOutput(response.content)
|
||||||
setTruncated(response.truncated)
|
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(() => {
|
.catch(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setOutput("Failed to load output.")
|
setRawOutput("Failed to load output.")
|
||||||
|
setAnsiEnabled(false)
|
||||||
|
setOutputHtml("")
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -45,8 +80,36 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||||
if (payload?.type === "chunk" && typeof payload.content === "string") {
|
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
|
||||||
setOutput((prev) => `${prev}${payload.content}`)
|
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 {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
@@ -90,9 +153,19 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
<Show when={truncated()}>
|
<Show when={truncated()}>
|
||||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
||||||
</Show>
|
</Show>
|
||||||
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
|
<Show
|
||||||
{output()}
|
when={ansiEnabled()}
|
||||||
</pre>
|
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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -4,11 +4,38 @@ 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_SGR_PATTERN = /\u001b\[[0-9;]*m/
|
||||||
const ANSI_NON_SGR_PATTERN = /\u001b\[[0-9;?]*[A-Za-ln-zA-LN-Z]/g
|
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 ansiConverter = new AnsiToHtml({
|
||||||
escapeXML: true,
|
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_SGR_PATTERN.test(normalized)
|
||||||
@@ -20,6 +47,45 @@ export function ansiToHtml(text: string): string {
|
|||||||
return ansiConverter.toHtml(sanitized)
|
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 {
|
function normalizeAnsiText(text: string): string {
|
||||||
if (!ANSI_LITERAL_PATTERN.test(text)) {
|
if (!ANSI_LITERAL_PATTERN.test(text)) {
|
||||||
return text
|
return text
|
||||||
@@ -34,3 +100,174 @@ function normalizeAnsiText(text: string): string {
|
|||||||
function stripNonSgrAnsi(text: string): string {
|
function stripNonSgrAnsi(text: string): string {
|
||||||
return text.replace(ANSI_NON_SGR_PATTERN, "")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user