diff --git a/package-lock.json b/package-lock.json index f582c5c4..a2bb2ef5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } - } + }, } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 098a9c26..be5177ed 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 5e898dcc..71abed0b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -6,8 +6,9 @@ const log = getLogger("session") const markdownRenderCache = new Map() -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) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 7cd90411..51f109b4 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -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() + if (cached) { + if (options.requireAnsi && !cached.hasAnsi) { + return null + } + return ( +
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> +
+          {scrollHelpers.renderSentinel()}
+        
+ ) + } + + 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 ( +
scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}> +
+        {scrollHelpers.renderSentinel()}
+      
+ ) + } + 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() 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, } diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 9683043f..c8da9d00 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -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 }) }, } diff --git a/packages/ui/src/components/tool-call/tool-title.ts b/packages/ui/src/components/tool-call/tool-title.ts index 2e8496be..a87d22fb 100644 --- a/packages/ui/src/components/tool-call/tool-title.ts +++ b/packages/ui/src/components/tool-call/tool-title.ts @@ -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, } diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index d0cb8e00..aa2e9957 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -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 partVersion?: Accessor renderMarkdown(options: MarkdownRenderOptions): JSXElement | null + renderAnsi(options: AnsiRenderOptions): JSXElement | null renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null scrollHelpers?: ToolScrollHelpers } diff --git a/packages/ui/src/lib/ansi.ts b/packages/ui/src/lib/ansi.ts new file mode 100644 index 00000000..b4b52211 --- /dev/null +++ b/packages/ui/src/lib/ansi.ts @@ -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, "") +} diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts index 4e9c6c5b..9e89ee71 100644 --- a/packages/ui/src/stores/message-v2/record-display-cache.ts +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -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 }