Add ANSI rendering for bash tool output
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user