Add ANSI rendering for bash tool output

This commit is contained in:
Shantur Rathore
2025-12-26 10:47:53 +00:00
parent 3606d9aa50
commit 71479a59a7
9 changed files with 171 additions and 20 deletions

27
package-lock.json generated
View File

@@ -2313,6 +2313,30 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"license": "MIT",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ansi-to-html/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -9047,6 +9071,7 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"ansi-to-html": "^0.7.2",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
@@ -9064,6 +9089,6 @@
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
}
}
},
}
}

View File

@@ -17,6 +17,7 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"ansi-to-html": "^0.7.2",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",

View File

@@ -6,8 +6,9 @@ const log = getLogger("session")
const markdownRenderCache = new Map<string, RenderCache>()
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) {
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean, versionKey: string) {
const versionSegment = versionKey.length > 0 ? versionKey : "noversion"
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}:${versionSegment}`
}
interface MarkdownProps {
@@ -35,19 +36,28 @@ export function Markdown(props: MarkdownProps) {
const themeKey = dark ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
const versionKey = typeof part.version === "number" ? String(part.version) : ""
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled, versionKey)
latestRequestedText = text
const localCache = part.renderCache
if (localCache && localCache.text === text && localCache.theme === themeKey) {
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
if (versionKey.length > 0) {
return cache.mode === versionKey && cache.theme === themeKey
}
return cache.text === text && cache.theme === themeKey
}
if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html)
notifyRendered()
return
}
const globalCache = markdownRenderCache.get(cacheKey)
if (globalCache && globalCache.text === text) {
if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered()
@@ -61,7 +71,7 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
@@ -70,7 +80,7 @@ export function Markdown(props: MarkdownProps) {
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
@@ -84,7 +94,7 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: versionKey || undefined }
setHtml(rendered)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)
@@ -93,7 +103,7 @@ export function Markdown(props: MarkdownProps) {
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
const cacheEntry: RenderCache = { text, html: text, theme: themeKey, mode: versionKey || undefined }
setHtml(text)
part.renderCache = cacheEntry
markdownRenderCache.set(cacheKey, cacheEntry)

View File

@@ -13,6 +13,7 @@ import type {
DiffPayload,
DiffRenderOptions,
MarkdownRenderOptions,
AnsiRenderOptions,
ToolCallPart,
ToolRendererContext,
ToolScrollHelpers,
@@ -20,11 +21,15 @@ import type {
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { ansiToHtml, hasAnsi } from "../lib/ansi"
import { escapeHtml } from "../lib/markdown"
const log = getLogger("session")
type ToolState = import("@opencode-ai/sdk").ToolState
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
@@ -228,21 +233,29 @@ export default function ToolCall(props: ToolCallProps) {
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const createVariantCache = (variant: string) =>
const createVariantCache = (variant: string | (() => string)) =>
useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: TOOL_CALL_CACHE_SCOPE,
key: () => {
const context = cacheContext()
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
const resolvedVariant = typeof variant === "function" ? variant() : variant
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant)
},
})
const diffCache = createVariantCache("diff")
const permissionDiffCache = createVariantCache("permission-diff")
const markdownCache = createVariantCache("markdown")
const ansiRunningCache = createVariantCache(() => {
const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion"
return `ansi-running:${versionKey}`
})
const ansiFinalCache = createVariantCache(() => {
const versionKey = typeof props.partVersion === "number" ? String(props.partVersion) : "noversion"
return `ansi-final:${versionKey}`
})
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
const pendingPermission = createMemo(() => {
const state = permissionState()
@@ -619,6 +632,49 @@ export default function ToolCall(props: ToolCallProps) {
)
}
function renderAnsiContent(options: AnsiRenderOptions) {
if (!options.content) {
return null
}
const size = options.size || "default"
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
const cached = cacheHandle.get<AnsiRenderCache>()
if (cached) {
if (options.requireAnsi && !cached.hasAnsi) {
return null
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={cached.html} />
{scrollHelpers.renderSentinel()}
</div>
)
}
const detectedAnsi = hasAnsi(options.content)
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
const cacheEntry: AnsiRenderCache = {
text: "",
html,
mode: typeof props.partVersion === "number" ? String(props.partVersion) : undefined,
hasAnsi: detectedAnsi,
}
cacheHandle.set(cacheEntry)
if (options.requireAnsi && !detectedAnsi) {
return null
}
return (
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={html} />
{scrollHelpers.renderSentinel()}
</div>
)
}
function renderMarkdownContent(options: MarkdownRenderOptions) {
if (!options.content) {
return null
@@ -639,7 +695,7 @@ export default function ToolCall(props: ToolCallProps) {
)
}
const markdownPart: TextPart = { type: "text", text: options.content }
const markdownPart: TextPart = { type: "text", text: options.content, version: props.partVersion }
const cached = markdownCache.get<RenderCache>()
if (cached) {
markdownPart.renderCache = cached
@@ -675,6 +731,7 @@ export default function ToolCall(props: ToolCallProps) {
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent,
renderAnsi: renderAnsiContent,
renderDiff: renderDiffContent,
scrollHelpers,
}

View File

@@ -20,7 +20,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · Timeout: ${timeoutLabel}`
},
renderBody({ toolState, renderMarkdown }) {
renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState()
if (!state || state.status === "pending") return null
@@ -36,9 +36,19 @@ export const bashRenderer: ToolRenderer = {
const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null
const content = ensureMarkdownContent(parts.join("\n"), "bash", true)
const joined = parts.join("\n")
if (state.status === "running") {
return renderAnsi({ content: joined, variant: "running" })
}
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
if (ansiBody) {
return ansiBody
}
const content = ensureMarkdownContent(joined, "bash", true)
if (!content) return null
return renderMarkdown({ content, disableHighlight: state.status === "running" })
return renderMarkdown({ content })
},
}

View File

@@ -48,6 +48,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null
return {
@@ -57,6 +58,7 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown,
renderAnsi,
renderDiff,
scrollHelpers: undefined,
}

View File

@@ -15,6 +15,13 @@ export interface MarkdownRenderOptions {
disableHighlight?: boolean
}
export interface AnsiRenderOptions {
content: string
size?: "default" | "large"
requireAnsi?: boolean
variant?: "running" | "final"
}
export interface DiffRenderOptions {
variant?: string
disableScrollTracking?: boolean
@@ -34,6 +41,7 @@ export interface ToolRendererContext {
messageVersion?: Accessor<number | undefined>
partVersion?: Accessor<number | undefined>
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
renderAnsi(options: AnsiRenderOptions): JSXElement | null
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
scrollHelpers?: ToolScrollHelpers
}

View File

@@ -0,0 +1,36 @@
import AnsiToHtml from "ansi-to-html"
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 ansiConverter = new AnsiToHtml({
escapeXML: true,
})
export function hasAnsi(text: string): boolean {
const normalized = normalizeAnsiText(text)
return ANSI_SGR_PATTERN.test(normalized)
}
export function ansiToHtml(text: string): string {
const normalized = normalizeAnsiText(text)
const sanitized = stripNonSgrAnsi(normalized)
return ansiConverter.toHtml(sanitized)
}
function normalizeAnsiText(text: string): string {
if (!ANSI_LITERAL_PATTERN.test(text)) {
return text
}
return text
.replace(/\\u001b/gi, ESC_CHAR)
.replace(/\\x1b/gi, ESC_CHAR)
.replace(/\\033/g, ESC_CHAR)
}
function stripNonSgrAnsi(text: string): string {
return text.replace(ANSI_NON_SGR_PATTERN, "")
}

View File

@@ -1,8 +1,10 @@
import type { ClientPart } from "../../types/message"
import type { MessageRecord } from "./types"
type ClientPartWithRevision = ClientPart & { revision?: number }
export interface RecordDisplayData {
orderedParts: ClientPart[]
orderedParts: ClientPartWithRevision[]
}
interface RecordDisplayCacheEntry {
@@ -23,12 +25,12 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord
return cached.data
}
const orderedParts: ClientPart[] = []
const orderedParts: ClientPartWithRevision[] = []
for (const partId of record.partIds) {
const entry = record.parts[partId]
if (!entry?.data) continue
orderedParts.push(entry.data)
orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision })
}
const data: RecordDisplayData = { orderedParts }