diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d91363954a1..4009b98a4a7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -30,6 +30,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" +import { Color } from "@/util/color" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -1298,9 +1299,84 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass ) } +function findColorCodePositions(text: string): Array<{ start: number; end: number; color: string }> { + const result: Array<{ start: number; end: number; color: string }> = [] + const regex = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g + let match + + while ((match = regex.exec(text)) !== null) { + result.push({ + start: match.index, + end: match.index + match[0].length, + color: match[0], + }) + } + + return result +} + +function colorCodePatcher( + syntaxStyle: () => ReturnType["syntax"]>, + background: () => RGBA, +) { + const patchClient = (client: any) => { + if (!client || client._colorPatched) return + client._colorPatched = true + + const original = client.highlightOnce.bind(client) + + client.highlightOnce = async (content: string, filetype: string) => { + const result = await original(content, filetype) + const colors = findColorCodePositions(content) + + if (colors.length > 0 && result.highlights) { + result.highlights = result.highlights.filter((h: [number, number, string, any?]) => { + const [start, end] = h + return !colors.some((pos) => start < pos.end && end > pos.start) + }) + + const bg = background() + for (const pos of colors) { + const rgba = Color.hexToRgba(pos.color) + const themeBg = { + r: Math.round(bg.r * 255), + g: Math.round(bg.g * 255), + b: Math.round(bg.b * 255), + } + const blended = Color.blend(rgba, themeBg) + const fg = Color.getContrastColor(blended) + const name = `color.${pos.color.replace("#", "")}` + + // Always re-register to handle theme changes + syntaxStyle().registerStyle(name, { + fg: RGBA.fromInts(fg.r, fg.g, fg.b), + bg: RGBA.fromInts(blended.r, blended.g, blended.b), + }) + + result.highlights.push([pos.start, pos.end, name, {}]) + } + } + + return result + } + } + + const patchAll = (node: any) => { + if (!node) return + if (node._treeSitterClient) patchClient(node._treeSitterClient) + if (typeof node.getChildren === "function") { + for (const child of node.getChildren()) patchAll(child) + } + } + + return patchAll +} + function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() + const patchedColorCodes = colorCodePatcher(syntax, () => theme.background) + return ( @@ -1312,6 +1388,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess content={props.part.text.trim()} conceal={ctx.conceal()} fg={theme.text} + ref={patchedColorCodes} /> diff --git a/packages/opencode/src/util/color.ts b/packages/opencode/src/util/color.ts index b96deaec478..b2cdcd440a7 100644 --- a/packages/opencode/src/util/color.ts +++ b/packages/opencode/src/util/color.ts @@ -4,16 +4,50 @@ export namespace Color { return /^#[0-9a-fA-F]{6}$/.test(hex) } + export function hexToRgba(hex: string): { r: number; g: number; b: number; a: number } { + const color = hex.replace("#", "") + const hasAlpha = color.length === 4 || color.length === 8 + + const expanded = + color.length <= 4 + ? color + .split("") + .map((c) => c + c) + .join("") + : color + + const r = parseInt(expanded.slice(0, 2), 16) + const g = parseInt(expanded.slice(2, 4), 16) + const b = parseInt(expanded.slice(4, 6), 16) + const a = hasAlpha ? parseInt(expanded.slice(6, 8), 16) / 255 : 1 + + return { r, g, b, a } + } + export function hexToRgb(hex: string): { r: number; g: number; b: number } { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) + const { r, g, b } = hexToRgba(hex) return { r, g, b } } + export function blend( + fg: { r: number; g: number; b: number; a: number }, + bg: { r: number; g: number; b: number }, + ): { r: number; g: number; b: number } { + return { + r: Math.round(fg.r * fg.a + bg.r * (1 - fg.a)), + g: Math.round(fg.g * fg.a + bg.g * (1 - fg.a)), + b: Math.round(fg.b * fg.a + bg.b * (1 - fg.a)), + } + } + export function hexToAnsiBold(hex?: string): string | undefined { if (!isValidHex(hex)) return undefined const { r, g, b } = hexToRgb(hex) return `\x1b[38;2;${r};${g};${b}m\x1b[1m` } + + export function getContrastColor(color: { r: number; g: number; b: number }): { r: number; g: number; b: number } { + const luminance = (0.299 * color.r + 0.587 * color.g + 0.114 * color.b) / 255 + return luminance > 0.5 ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 } + } }