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

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
}