import type { ThemeColor } from "@earendil-works/pi-coding-agent"; import type { ColorSpec } from "../app/config/shell.ts"; type ThemeLike = { fg(color: string, text: string): string; bold?: (text: string) => string; italic?: (text: string) => string; underline?: (text: string) => string; }; export type { ThemeLike }; export const MAX_SAFE_SGR_PREFIX_CODE_UNITS = 96; export const MAX_SAFE_SGR_PREFIX_SEQUENCES = 4; /** * 单模式下的默认回退色。Editor 与用户消息的强调/边框默认跟随主题 * 语义色(unconfigured 时与 Pi 原生默认一致)。 */ export const EDITOR_ACCENT_FALLBACK: ColorSpec = "accent"; export const EDITOR_BORDER_FALLBACK: ColorSpec = "borderMuted"; function isHexColor(value: string): boolean { return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); } function expandHexColor(hex: string): string { const body = hex.slice(1); if (body.length === 3) { return body .split("") .map((ch) => ch + ch) .join(""); } return body; } function hexToAnsi(hex: string, isBackground = false): string { const normalized = expandHexColor(hex); const r = Number.parseInt(normalized.slice(0, 2), 16); const g = Number.parseInt(normalized.slice(2, 4), 16); const b = Number.parseInt(normalized.slice(4, 6), 16); return `\x1b[${isBackground ? 48 : 38};2;${r};${g};${b}m`; } const terminalColorCodes = new Map([ ["black", 30], ["red", 31], ["green", 32], ["yellow", 33], ["blue", 34], ["purple", 35], ["cyan", 36], ["white", 37], ["bright-black", 90], ["bright-red", 91], ["bright-green", 92], ["bright-yellow", 93], ["bright-blue", 94], ["bright-purple", 95], ["bright-cyan", 96], ["bright-white", 97], ]); const terminalStyleModifiers = new Map([ ["bold", 1], ["dim", 2], ["dimmed", 2], ["italic", 3], ["underline", 4], ]); /** * ANSI 色名到主题语义 token 的映射:theme 语义下写 `red` 得到主题错误色, * 写 `green` 得到主题成功色,保证默认值与主题观感一致。 */ const themeColorNameMap = new Map([ ["red", "error"], ["bright-red", "error"], ["green", "success"], ["bright-green", "success"], ["yellow", "warning"], ["bright-yellow", "warning"], ["blue", "syntaxFunction"], ["bright-blue", "syntaxFunction"], ["cyan", "syntaxFunction"], ["bright-cyan", "syntaxFunction"], ["purple", "syntaxKeyword"], ["bright-purple", "syntaxKeyword"], ["black", "muted"], ["bright-black", "muted"], ["white", "text"], ["bright-white", "text"], ]); const themeStyleModifiers = new Set(["bold", "italic", "underline"]); const themeColorTokens = new Set([ "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text", "thinkingText", "userMessageText", "customMessageText", "customMessageLabel", "toolTitle", "toolOutput", "mdHeading", "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder", "mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation", "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh", "thinkingMax", "bashMode", ]); function terminalColorToAnsi( color: string, isBackground = false, ): string | undefined { const normalized = color.toLowerCase(); const colorCode = terminalColorCodes.get(normalized); if (colorCode !== undefined) return `${isBackground ? colorCode + 10 : colorCode}`; if (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized)) { return `${isBackground ? 48 : 38};5;${normalized}`; } if (isHexColor(normalized)) return hexToAnsi(normalized, isBackground).slice(2, -1); return undefined; } /** * 显式终端 token:hex、0-255 数字、fg:/bg: 前缀。这类值在 theme 语义下 * 仍直接输出固定终端色,不经过主题。 */ function isExplicitTerminalColorToken(token: string): boolean { const normalized = token.toLowerCase(); if (normalized.startsWith("fg:") || normalized.startsWith("bg:")) return true; if (isHexColor(normalized)) return true; return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test( normalized, ); } function isSupportedStyleToken(token: string): boolean { const normalized = token.toLowerCase(); if (terminalStyleModifiers.has(normalized)) return true; if (terminalColorToAnsi(normalized) !== undefined) return true; const isForeground = normalized.startsWith("fg:"); const isBackground = normalized.startsWith("bg:"); if (isForeground || isBackground) { return terminalColorToAnsi(normalized.slice(3), isBackground) !== undefined; } return themeColorTokens.has(token as ThemeColor); } export function isSupportedColorSpec(style: ColorSpec): boolean { const trimmed = style.trim(); if (trimmed === "") return true; return trimmed.split(/\s+/).every(isSupportedStyleToken); } function applyThemeModifiers( theme: ThemeLike, styleTokens: string[], text: string, ): string { let rendered = text; for (const token of styleTokens) { const normalized = token.toLowerCase(); if (normalized === "bold") rendered = theme.bold?.(rendered) ?? rendered; if (normalized === "italic") rendered = theme.italic?.(rendered) ?? rendered; if (normalized === "underline") rendered = theme.underline?.(rendered) ?? rendered; } return rendered; } export function safeThemeFg( theme: ThemeLike, color: string, text: string, ): string { try { return theme.fg(color, text); } catch { return text; } } function mapThemeColor(styleTokens: string[]): string | undefined { let fallback: string | undefined; for (const token of styleTokens) { const normalized = token.toLowerCase(); if (themeStyleModifiers.has(normalized)) continue; if (normalized === "dim" || normalized === "dimmed") { fallback = "muted"; continue; } const mapped = themeColorNameMap.get(normalized); if (mapped) return mapped; return token; } return fallback; } /** * Colorize text using a theme color token or hex color. * Non-hex values are passed directly to `theme.fg()`; invalid tokens fall back * to unstyled text so a config typo does not break rendering. */ export function colorize( theme: ThemeLike, color: ColorSpec, text: string, ): string { if (isHexColor(color)) { return `${hexToAnsi(color)}${text}\x1b[39m`; } return safeThemeFg(theme, color, text); } /** * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202", * "bg:blue", "underline bg:#bf5700"). */ function isSafeSgrParameters(parameters: string): boolean { const tokens = parameters.split(";"); if (tokens.some((token) => !/^\d+$/.test(token))) return false; const values = tokens.map(Number); for (let index = 0; index < values.length; index += 1) { const value = values[index]; if ( value === 1 || value === 2 || value === 3 || value === 4 || (value !== undefined && value >= 30 && value <= 37) || (value !== undefined && value >= 40 && value <= 47) || (value !== undefined && value >= 90 && value <= 97) || (value !== undefined && value >= 100 && value <= 107) ) { continue; } if (value !== 38 && value !== 48) return false; const mode = values[index + 1]; if (mode === 5) { const color = values[index + 2]; if (color === undefined || color < 0 || color > 255) return false; index += 2; continue; } if (mode === 2) { const channels = values.slice(index + 2, index + 5); if ( channels.length !== 3 || channels.some((channel) => channel < 0 || channel > 255) ) { return false; } index += 4; continue; } return false; } return true; } /** Accept only the bounded SGR subset emitted by Zentui's supported style tokens. */ export function isSafeSgrStylePrefix(value: unknown): value is string { if ( typeof value !== "string" || value.length === 0 || value.length > MAX_SAFE_SGR_PREFIX_CODE_UNITS ) { return false; } const sequence = /\x1b\[([0-9]+(?:;[0-9]+)*)m/g; let offset = 0; let count = 0; for (const match of value.matchAll(sequence)) { if (match.index !== offset || !isSafeSgrParameters(match[1] ?? "")) return false; offset = match.index + match[0].length; count += 1; if (count > MAX_SAFE_SGR_PREFIX_SEQUENCES) return false; } return count > 0 && offset === value.length; } export function renderTerminalStyle(style: string, text: string): string { const codes: string[] = []; for (const token of style.trim().split(/\s+/)) { if (!token) continue; const normalized = token.toLowerCase(); const modifier = terminalStyleModifiers.get(normalized); if (modifier !== undefined) { codes.push(`${modifier}`); continue; } const isForeground = normalized.startsWith("fg:"); const isBackground = normalized.startsWith("bg:"); const colorName = isForeground || isBackground ? normalized.slice(3) : normalized; const color = terminalColorToAnsi(colorName, isBackground); if (color) codes.push(color); } return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text; } /** * 单模式颜色渲染:配置值按 theme 语义解释。 * - 含显式终端 token(hex/数字/fg:/bg:)→ 直接输出固定终端色; * - 其余按主题 token 解析(ANSI 色名映射到语义 token,如 red→error), * 修饰符通过主题的 bold/italic/underline 应用; * - 空值原样返回。 */ export function renderThemeStyle( theme: ThemeLike, style: ColorSpec, text: string, ): string { const trimmed = style.trim(); if (trimmed === "") return text; const tokens = trimmed.split(/\s+/).filter(Boolean); if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text); const color = mapThemeColor(tokens) ?? "text"; return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text)); } function renderThemeStyleStrict( theme: ThemeLike, style: ColorSpec, text: string, ): string { const trimmed = style.trim(); if (trimmed === "") return text; const tokens = trimmed.split(/\s+/).filter(Boolean); if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text); const color = mapThemeColor(tokens) ?? "text"; return theme.fg(color, applyThemeModifiers(theme, tokens, text)); } /** * 配置优先、未配置回退默认的渲染入口(fallback 为单一主题语义色)。 */ export function renderThemeStyleOrFallback( theme: ThemeLike, style: ColorSpec | undefined, fallback: ColorSpec, text: string, ): string { return renderThemeStyle(theme, style ?? fallback, text); } export function renderThemeStyleOrFallbackStrict( theme: ThemeLike, style: ColorSpec | undefined, fallback: ColorSpec, text: string, ): string { return renderThemeStyleStrict(theme, style ?? fallback, text); } /** * 主题画板存在性探测:pi Theme 暴露 fgColors Map;测试或其他实现可 * 提供 hasThemeToken 回调,缺失时保守按"无该 token"处理。 */ function themeHasToken(theme: ThemeLike, token: string): boolean { if ( typeof (theme as { hasThemeToken?: (t: string) => boolean }) .hasThemeToken === "function" ) { return ( theme as unknown as { hasThemeToken(t: string): boolean } ).hasThemeToken(token); } const fgColors = (theme as unknown as { fgColors?: Map }) .fgColors; return fgColors instanceof Map && fgColors.has(token); } /** * 颜色统一渲染入口(配置优先,其次主题 token,最后原生默认): * - 配置了 spec:按 theme 语义解释(token/hex/数字/fg:/bg:); * - 未配置:主题定义了 themeToken(cwd/editorModel/editorBorder)时用主题色, * 否则回落原生默认 themeDefault。 */ export function renderSourceColor( theme: ThemeLike, spec: ColorSpec | undefined, themeToken: string, themeDefault: ColorSpec, text: string, ): string { if (typeof spec === "string" && spec.trim() !== "") { return renderThemeStyle(theme, spec, text); } if (themeHasToken(theme, themeToken)) { try { return theme.fg(themeToken, text); } catch { // 主题色解析失败时继续回退原生默认。 } } return renderThemeStyle(theme, themeDefault, text); } export function renderEditorBorder(text: string): string { return renderTerminalStyle("bright-black", text); }