feat(tui): render LaTeX as Unicode

This commit is contained in:
chengyongru
2026-08-22 01:46:12 +08:00
committed by chengyongru
parent a4acd83966
commit 375185210b
5 changed files with 1023 additions and 5 deletions
+29
View File
@@ -1368,6 +1368,35 @@ describe("NanobotTui layout", () => {
}
})
test("renders assistant LaTeX as Unicode text without changing code", async () => {
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({
event: "delta",
chat_id: "chat",
text: [
"缓存率:",
"\\[\\text{缓存率}=\\frac{\\text{cached input tokens}}{\\text{total input tokens}}\\]",
"结果:\\(66{,}000 \\times 94\\% \\approx 62{,}040\\)",
"`\\(code\\)`",
].join("\n"),
})
app.accept({ event: "stream_end", chat_id: "chat" })
const transcript = (app as unknown as {
transcript: { assistant(content: string): void }
}).transcript
transcript.assistant("历史公式:\\(x_1^2 + y_2^2 = z^2\\)")
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain("缓存率 = cached input tokens / total input tokens")
expect(frame).toContain("66,000 × 94% ≈ 62,040")
expect(frame).toContain("历史公式:x₁² + y₂² = z²")
expect(frame).toContain("\\(code\\)")
expect(frame).not.toContain("\\frac")
expect(frame).not.toContain("\\text")
})
test("rethemes the complete retained interface when the terminal appearance changes", async () => {
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const app = mount(setup)
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, test } from "bun:test"
import { renderLatexAsUnicode } from "./latex"
describe("terminal LaTeX rendering", () => {
test("renders the cache-rate example as readable Unicode text", () => {
expect(renderLatexAsUnicode([
"实际公式是:",
"",
"\\[",
"\\text{缓存率}=\\frac{\\text{cached input tokens}}{\\text{total input tokens}}",
"\\]",
"",
"缓存命中:\\(66{,}000 \\times 94\\% \\approx 62{,}040\\) tokens",
].join("\n"))).toBe([
"实际公式是:",
"",
"缓存率 = cached input tokens / total input tokens",
"",
"缓存命中:66,000 × 94% ≈ 62,040 tokens",
].join("\n"))
})
test("converts common symbols, scripts, roots, fractions, and accents", () => {
const source = "$\\hat{f}(\\xi)=\\sum_{n=0}^{\\infty} \\frac{x^n}{n!}$ and \\(x_1=\\sqrt[3]{\\alpha+\\beta}\\)"
expect(renderLatexAsUnicode(source)).toBe(
"f̂(ξ) = ∑ₙ₌₀^(∞) xⁿ / n! and x₁ = ³√(α+β)",
)
})
test("preserves precedence for compound fraction sides", () => {
expect(renderLatexAsUnicode("\\(\\frac{a-b}{c} + \\frac{a}{b \\times c}\\)"))
.toBe("(a-b) / c + a / (b × c)")
})
test("keeps currency and price ranges literal while converting guarded dollar math", () => {
const source = [
"Costs are $24 today or $10-20 later; variables $x$ and $2^n$ are math.",
"Shipping costs $5+$10 and paths use $HOME/$USER.",
"Matrix $A$ maps $V$ to $W$.",
].join("\n")
expect(renderLatexAsUnicode(source)).toBe([
"Costs are $24 today or $10-20 later; variables x and 2ⁿ are math.",
"Shipping costs $5+$10 and paths use $HOME/$USER.",
"Matrix A maps V to W.",
].join("\n"))
})
test("supports display dollars and aligned line breaks", () => {
const source = "$$\\begin{aligned}a&=b+c\\\\d&\\le e\\end{aligned}$$"
expect(renderLatexAsUnicode(source)).toBe("a = b+c\nd ≤ e")
})
test("leaves inline and fenced code unchanged", () => {
const source = [
"Inline `\\(x^2\\)` stays literal, but \\(y^2\\) renders.",
"",
"```tex",
"\\[\\frac{a}{b}\\]",
"```",
].join("\n")
expect(renderLatexAsUnicode(source)).toBe([
"Inline `\\(x^2\\)` stays literal, but y² renders.",
"",
"```tex",
"\\[\\frac{a}{b}\\]",
"```",
].join("\n"))
})
test("leaves fenced code inside block quotes and lists unchanged", () => {
const source = [
"> ~~~tex",
"> $x^2$",
"> ~~~",
"",
"- ```tex",
" \\(y_1\\)",
" ```",
"",
"1. item",
"",
" ~~~tex",
" $z^2$",
" ~~~",
].join("\n")
expect(renderLatexAsUnicode(source)).toBe(source)
})
test("leaves unmatched delimiters available during streaming", () => {
expect(renderLatexAsUnicode("Working on \\(x^2")).toBe("Working on \\(x^2")
expect(renderLatexAsUnicode("Working $$x$")).toBe("Working $$x$")
expect(renderLatexAsUnicode("An escaped \\$5 stays literal.")).toBe("An escaped \\$5 stays literal.")
})
test("handles many unmatched openers without changing them", () => {
const openers = "\\(".repeat(20_000)
const backslashes = "\\".repeat(20_000)
expect(renderLatexAsUnicode(openers)).toBe(openers)
expect(renderLatexAsUnicode(backslashes)).toBe(backslashes)
})
test("leaves unsupported, malformed, and deeply nested math unchanged", () => {
const deep = `{`.repeat(100) + "x" + `}`.repeat(100)
const source = [
"$\\color{red}{x}$",
"$\\frac{a}$",
`\\(${deep}\\)`,
"\\(\\displaystyle x^2\\)",
].join(" ")
expect(renderLatexAsUnicode(source)).toBe([
"$\\color{red}{x}$",
"$\\frac{a}$",
`\\(${deep}\\)`,
"x²",
].join(" "))
})
test("does not rewrite Markdown link destinations or autolinks", () => {
const source = [
"[formula $x$](https://example.test/$x$/\\(raw\\))",
"<https://example.test/$y$>",
"bare https://example.test/$z$ remains intact",
"<span title=\"$raw$\">HTML</span>",
"if a < $x$ > b",
].join("\n")
expect(renderLatexAsUnicode(source)).toBe([
"[formula x](https://example.test/$x$/\\(raw\\))",
"<https://example.test/$y$>",
"bare https://example.test/$z$ remains intact",
"<span title=\"$raw$\">HTML</span>",
"if a < x > b",
].join("\n"))
})
})
+841
View File
@@ -0,0 +1,841 @@
const MATH_SYMBOLS: Readonly<Record<string, string>> = {
alpha: "α",
beta: "β",
gamma: "γ",
delta: "δ",
epsilon: "ε",
varepsilon: "ϵ",
zeta: "ζ",
eta: "η",
theta: "θ",
vartheta: "ϑ",
iota: "ι",
kappa: "κ",
lambda: "λ",
mu: "μ",
nu: "ν",
xi: "ξ",
omicron: "ο",
pi: "π",
varpi: "ϖ",
rho: "ρ",
varrho: "ϱ",
sigma: "σ",
varsigma: "ς",
tau: "τ",
upsilon: "υ",
phi: "φ",
varphi: "ϕ",
chi: "χ",
psi: "ψ",
omega: "ω",
Gamma: "Γ",
Delta: "Δ",
Theta: "Θ",
Lambda: "Λ",
Xi: "Ξ",
Pi: "Π",
Sigma: "Σ",
Upsilon: "Υ",
Phi: "Φ",
Psi: "Ψ",
Omega: "Ω",
times: " × ",
div: " ÷ ",
cdot: " · ",
ast: " ",
pm: " ± ",
mp: " ∓ ",
approx: " ≈ ",
sim: " ",
simeq: " ≃ ",
cong: " ≅ ",
equiv: " ≡ ",
ne: " ≠ ",
neq: " ≠ ",
le: " ≤ ",
leq: " ≤ ",
ge: " ≥ ",
geq: " ≥ ",
ll: " ≪ ",
gg: " ≫ ",
propto: " ∝ ",
to: " → ",
gets: " ← ",
rightarrow: " → ",
leftarrow: " ← ",
leftrightarrow: " ↔ ",
Rightarrow: " ⇒ ",
Leftarrow: " ⇐ ",
Leftrightarrow: " ⇔ ",
mapsto: " ↦ ",
in: " ∈ ",
ni: " ∋ ",
notin: " ∉ ",
subset: " ⊂ ",
supset: " ⊃ ",
subseteq: " ⊆ ",
supseteq: " ⊇ ",
cup: " ",
cap: " ∩ ",
setminus: " ",
emptyset: "∅",
varnothing: "∅",
forall: "∀",
exists: "∃",
neg: "¬",
land: " ∧ ",
lor: " ",
wedge: " ∧ ",
vee: " ",
sum: "∑",
prod: "∏",
coprod: "∐",
int: "∫",
iint: "∬",
iiint: "∭",
oint: "∮",
partial: "∂",
nabla: "∇",
infty: "∞",
ell: "",
hbar: "ℏ",
Re: "",
Im: "",
angle: "∠",
degree: "°",
prime: "",
ldots: "…",
cdots: "⋯",
vdots: "⋮",
ddots: "⋱",
langle: "⟨",
rangle: "⟩",
lceil: "⌈",
rceil: "⌉",
lfloor: "⌊",
rfloor: "⌋",
vert: "|",
Vert: "‖",
}
const NAMED_FUNCTIONS = new Set([
"arccos",
"arcsin",
"arctan",
"cos",
"cosh",
"cot",
"coth",
"csc",
"deg",
"det",
"dim",
"exp",
"gcd",
"hom",
"inf",
"ker",
"lg",
"lim",
"liminf",
"limsup",
"ln",
"log",
"max",
"min",
"Pr",
"sec",
"sin",
"sinh",
"sup",
"tan",
"tanh",
])
const STYLE_COMMANDS = new Set([
"mathbf",
"mathbb",
"mathcal",
"mathfrak",
"mathit",
"mathrm",
"mathsf",
"mathtt",
"boldsymbol",
"bm",
])
const ACCENTS: Readonly<Record<string, string>> = {
acute: "\u0301",
bar: "\u0304",
breve: "\u0306",
check: "\u030c",
ddot: "\u0308",
dot: "\u0307",
grave: "\u0300",
hat: "\u0302",
overline: "\u0305",
tilde: "\u0303",
vec: "\u20d7",
}
const SUPER: Readonly<Record<string, string>> = {
"0": "⁰",
"1": "¹",
"2": "²",
"3": "³",
"4": "⁴",
"5": "⁵",
"6": "⁶",
"7": "⁷",
"8": "⁸",
"9": "⁹",
"+": "⁺",
"-": "⁻",
"=": "⁼",
"(": "⁽",
")": "⁾",
a: "ᵃ",
b: "ᵇ",
c: "ᶜ",
d: "ᵈ",
e: "ᵉ",
f: "ᶠ",
g: "ᵍ",
h: "ʰ",
i: "ⁱ",
j: "ʲ",
k: "ᵏ",
l: "ˡ",
m: "ᵐ",
n: "ⁿ",
o: "ᵒ",
p: "ᵖ",
r: "ʳ",
s: "ˢ",
t: "ᵗ",
u: "ᵘ",
v: "ᵛ",
w: "ʷ",
x: "ˣ",
y: "ʸ",
z: "ᶻ",
}
const SUB: Readonly<Record<string, string>> = {
"0": "₀",
"1": "₁",
"2": "₂",
"3": "₃",
"4": "₄",
"5": "₅",
"6": "₆",
"7": "₇",
"8": "₈",
"9": "₉",
"+": "₊",
"-": "₋",
"=": "₌",
"(": "₍",
")": "₎",
a: "ₐ",
e: "ₑ",
h: "ₕ",
i: "ᵢ",
j: "ⱼ",
k: "ₖ",
l: "ₗ",
m: "ₘ",
n: "ₙ",
o: "ₒ",
p: "ₚ",
r: "ᵣ",
s: "ₛ",
t: "ₜ",
u: "ᵤ",
v: "ᵥ",
x: "ₓ",
}
const MAX_MATH_LENGTH = 20_000
const MAX_GROUP_DEPTH = 64
/** Converts supported LaTeX math spans without changing the stored Markdown source. */
export function renderLatexAsUnicode(markdown: string): string {
let rendered = ""
let cursor = 0
const missingClosers = new Set<string>()
while (cursor < markdown.length) {
const fencedEnd = fencedCodeEnd(markdown, cursor)
if (fencedEnd !== null) {
rendered += markdown.slice(cursor, fencedEnd)
cursor = fencedEnd
continue
}
if (markdown[cursor] === "`") {
const inlineEnd = inlineCodeEnd(markdown, cursor)
if (inlineEnd !== null) {
rendered += markdown.slice(cursor, inlineEnd)
cursor = inlineEnd
continue
}
}
const destinationEnd = markdownDestinationEnd(markdown, cursor)
if (destinationEnd !== null) {
rendered += markdown.slice(cursor, destinationEnd)
cursor = destinationEnd
continue
}
const explicitOpening = explicitMathOpeningAt(markdown, cursor)
const math = mathSpanAt(markdown, cursor, missingClosers)
if (math) {
const converted = convertMath(math.content)
if (converted !== null) {
rendered += escapeMarkdown(converted)
cursor = math.end
continue
}
rendered += markdown.slice(cursor, math.end)
cursor = math.end
continue
}
if (explicitOpening) {
rendered += explicitOpening
cursor += explicitOpening.length
continue
}
rendered += markdown[cursor]
cursor += 1
}
return rendered
}
interface MathSpan {
content: string
end: number
}
interface ParseState {
valid: boolean
}
function convertMath(source: string): string | null {
if (!validMathStructure(source)) return null
const state: ParseState = { valid: true }
const converted = normalizeMath(new LatexParser(source, state).parse())
return state.valid && converted ? converted : null
}
function validMathStructure(source: string): boolean {
if (source.length > MAX_MATH_LENGTH) return false
let depth = 0
for (let cursor = 0; cursor < source.length; cursor += 1) {
const value = source[cursor]
if (value !== "{" && value !== "}") continue
if (isEscaped(source, cursor)) continue
if (value === "{") {
depth += 1
if (depth > MAX_GROUP_DEPTH) return false
} else if (source[cursor] === "}") {
depth -= 1
if (depth < 0) return false
}
}
return depth === 0
}
function explicitMathOpeningAt(markdown: string, start: number): string | null {
const opening = markdown.startsWith("\\[", start)
? "\\["
: markdown.startsWith("\\(", start)
? "\\("
: markdown.startsWith("$$", start)
? "$$"
: null
return opening && !isEscaped(markdown, start) ? opening : null
}
function mathSpanAt(
markdown: string,
start: number,
missingClosers: Set<string>,
): MathSpan | null {
const explicitOpening = explicitMathOpeningAt(markdown, start)
if (explicitOpening === "\\[") {
return delimitedMath(markdown, start, "\\[", "\\]", false, missingClosers)
}
if (explicitOpening === "\\(") {
return delimitedMath(markdown, start, "\\(", "\\)", true, missingClosers)
}
if (explicitOpening === "$$") {
return delimitedMath(markdown, start, "$$", "$$", false, missingClosers)
}
if (
markdown[start] !== "$"
|| markdown[start + 1] === "$"
|| isWhitespace(markdown[start + 1])
|| isEscaped(markdown, start)
) {
return null
}
const span = delimitedMath(markdown, start, "$", "$", true, missingClosers)
if (!span || isWhitespace(span.content.at(-1))) return null
const first = span.content[0]
if (first && /[0-9]/u.test(first) && /[0-9]/u.test(markdown[span.end] ?? "")) return null
if (first && /[0-9]/u.test(first) && !/[\\^_={}|+<>]/u.test(span.content)) return null
if (
/^[A-Z_][A-Z0-9_]*(?:\/|\s.*)$/u.test(span.content)
&& /[A-Z_]/u.test(markdown[span.end] ?? "")
&& !/[\\^_={}|+<>]/u.test(span.content)
) {
return null
}
return span
}
function delimitedMath(
source: string,
start: number,
opening: string,
closing: string,
singleLine: boolean,
missingClosers: Set<string>,
): MathSpan | null {
const contentStart = start + opening.length
const lineEnd = singleLine ? source.indexOf("\n", contentStart) : -1
const boundary = lineEnd < 0 ? source.length : lineEnd
const cacheKey = `${closing}:${boundary}`
if (missingClosers.has(cacheKey)) return null
for (let close = contentStart; close + closing.length <= boundary; close += 1) {
if (!source.startsWith(closing, close) || isEscaped(source, close)) continue
if (closing === "$" && (source[close - 1] === "$" || source[close + 1] === "$")) continue
const content = source.slice(start + opening.length, close)
return content ? { content, end: close + closing.length } : null
}
missingClosers.add(cacheKey)
return null
}
function fencedCodeEnd(source: string, start: number): number | null {
const marker = source[start]
if (marker !== "`" && marker !== "~") return null
const lineStart = source.lastIndexOf("\n", start - 1) + 1
const context = fenceContext(source.slice(lineStart, start))
if (!context) return null
const count = repeatedCharacterCount(source, start, marker)
if (count < 3) return null
const openingLineEnd = source.indexOf("\n", start + count)
if (openingLineEnd < 0) return source.length
let candidateStart = openingLineEnd + 1
while (candidateStart <= source.length) {
const candidateEnd = source.indexOf("\n", candidateStart)
const lineEnd = candidateEnd < 0 ? source.length : candidateEnd
const line = source.slice(candidateStart, lineEnd)
const markerOffset = closingFenceOffset(line, context)
if (markerOffset === null) {
if (candidateEnd < 0) break
candidateStart = candidateEnd + 1
continue
}
const markerStart = candidateStart + markerOffset
const closingCount = repeatedCharacterCount(source, markerStart, marker)
if (closingCount >= count && source.slice(markerStart + closingCount, lineEnd).trim() === "") {
return candidateEnd < 0 ? source.length : candidateEnd + 1
}
if (candidateEnd < 0) break
candidateStart = candidateEnd + 1
}
return source.length
}
interface FenceContext {
quoteDepth: number
maxIndent: number
}
function fenceContext(prefix: string): FenceContext | null {
let rest = prefix
let quoteDepth = 0
while (true) {
const quote = rest.match(/^[ \t]{0,3}>[ \t]?/u)?.[0]
if (!quote) break
quoteDepth += 1
rest = rest.slice(quote.length)
}
const list = rest.match(/^[ \t]*(?:[-+*]|[0-9]{1,9}[.)])[ \t]+/u)?.[0]
if (list) rest = rest.slice(list.length)
if (!/^[ \t]*$/u.test(rest)) return null
return { quoteDepth, maxIndent: Math.max(3, list?.length ?? 0, rest.length) }
}
function closingFenceOffset(line: string, context: FenceContext): number | null {
let offset = 0
for (let depth = 0; depth < context.quoteDepth; depth += 1) {
const quote = line.slice(offset).match(/^[ \t]{0,3}>[ \t]?/u)?.[0]
if (!quote) return null
offset += quote.length
}
const indent = line.slice(offset).match(new RegExp(`^[ \\t]{0,${context.maxIndent}}`, "u"))?.[0]
return indent === undefined ? null : offset + indent.length
}
function inlineCodeEnd(source: string, start: number): number | null {
const count = repeatedCharacterCount(source, start, "`")
const delimiter = "`".repeat(count)
const close = source.indexOf(delimiter, start + count)
return close < 0 ? null : close + count
}
function markdownDestinationEnd(source: string, start: number): number | null {
if (source[start] === "(" && source[start - 1] === "]" && !isEscaped(source, start - 1)) {
let depth = 1
let cursor = start + 1
while (cursor < source.length) {
const value = source[cursor]
if (value === "\n") return null
if (value === "\\") {
cursor += 2
continue
}
if (value === "(") depth += 1
else if (value === ")") {
depth -= 1
if (depth === 0) return cursor + 1
}
cursor += 1
}
return null
}
if (source[start] === "<") {
const angle = source.slice(start).match(
/^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*|[^<>\s@]+@[^<>\s@]+|\/?[A-Za-z][^<>\n]*)>/u,
)?.[0]
if (angle) return start + angle.length
}
const remainder = source.slice(start)
if (/^(?:https?:\/\/|mailto:)/u.test(remainder)) {
const match = remainder.match(/^[^\s<>]+/u)
return match ? start + match[0].length : null
}
return null
}
function repeatedCharacterCount(source: string, start: number, value: string): number {
let end = start
while (source[end] === value) end += 1
return end - start
}
function isEscaped(source: string, index: number): boolean {
let backslashes = 0
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
backslashes += 1
}
return backslashes % 2 === 1
}
function isWhitespace(value: string | undefined): boolean {
return value === undefined || /\s/u.test(value)
}
class LatexParser {
private cursor = 0
constructor(
private readonly source: string,
private readonly state: ParseState,
) {}
parse(): string {
let result = ""
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === undefined) break
if (/\s/u.test(value)) {
result += " "
this.cursor += 1
} else if (value === "\\") {
result += this.command()
} else if (value === "{") {
const group = this.rawGroup()
result += group === null ? "{" : new LatexParser(group, this.state).parse()
} else if (value === "^" || value === "_") {
this.cursor += 1
const argument = this.argument()
result += script(argument ?? "", value === "^" ? SUPER : SUB, value)
} else if (value === "&" || value === "~") {
result += " "
this.cursor += 1
} else {
result += value
this.cursor += 1
}
}
return result
}
private command(): string {
this.cursor += 1
const first = this.source[this.cursor]
if (first === undefined) return ""
if (!/[A-Za-z]/u.test(first)) {
this.cursor += 1
if (first === "\\") return "\n"
if (",:;! ".includes(first)) return " "
return first
}
const start = this.cursor
while (/[A-Za-z]/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
const name = this.source.slice(start, this.cursor)
if (this.source[this.cursor] === "*") this.cursor += 1
const symbol = MATH_SYMBOLS[name]
if (symbol !== undefined) return symbol
if (NAMED_FUNCTIONS.has(name)) return name
if ([
"left",
"right",
"limits",
"nolimits",
"displaystyle",
"textstyle",
"scriptstyle",
"scriptscriptstyle",
].includes(name)) return ""
if (["quad", "qquad", "enspace", "enskip"].includes(name)) return " "
if (["hspace", "vspace"].includes(name)) {
this.rawGroup()
return " "
}
if (["frac", "dfrac", "tfrac"].includes(name)) {
const numerator = this.argument()
const denominator = this.argument()
if (numerator !== null && denominator !== null) {
return `${fractionSide(numerator)} / ${fractionSide(denominator)}`
}
return "frac"
}
if (name === "sqrt") {
const root = this.optionalArgument()
const radicand = this.argument()
if (radicand === null) return "√"
const index = root ? script(root, SUPER, "^") : ""
return `${index}√(${radicand})`
}
if (["text", "textrm", "textsf", "texttt", "textnormal", "mbox"].includes(name)) {
const text = this.rawGroup()
return text === null ? name : plainText(text, this.state)
}
if (STYLE_COMMANDS.has(name)) return this.argument() ?? ""
if (name === "operatorname") {
const operator = this.rawGroup()
return operator === null ? "" : plainText(operator, this.state)
}
const accent = ACCENTS[name]
if (accent !== undefined) {
const argument = this.argument()
return argument === null ? "" : `${argument}${accent}`
}
if (name === "begin" || name === "end") {
const environment = this.rawGroup()
return name === "begin" && environment === "cases" ? "{\n" : ""
}
if (name === "not") return this.negatedSymbol()
if (name === "mod") return "mod"
if (name === "pmod") {
const value = this.argument()
return value === null ? "mod" : `(mod ${value})`
}
this.state.valid = false
return `\\${name}`
}
private negatedSymbol(): string {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] === "=") {
this.cursor += 1
return " ≠ "
}
if (this.source[this.cursor] !== "\\") return "¬"
const saved = this.cursor
this.cursor += 1
const start = this.cursor
while (/[A-Za-z]/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
const name = this.source.slice(start, this.cursor)
const negated: Readonly<Record<string, string>> = {
in: " ∉ ",
ni: " ∌ ",
subset: " ⊄ ",
supset: " ⊅ ",
subseteq: " ⊈ ",
supseteq: " ⊉ ",
}
const symbol = negated[name]
if (symbol !== undefined) return symbol
this.cursor = saved
return "¬"
}
private argument(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] === "{") {
const group = this.rawGroup()
return group === null ? null : new LatexParser(group, this.state).parse()
}
if (this.source[this.cursor] === "\\") return this.command()
const value = this.source[this.cursor]
if (value === undefined) {
this.state.valid = false
return null
}
this.cursor += 1
return value
}
private optionalArgument(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] !== "[") return null
const start = this.cursor + 1
let depth = 1
this.cursor += 1
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === "[") depth += 1
else if (value === "]") {
depth -= 1
if (depth === 0) {
const content = this.source.slice(start, this.cursor)
this.cursor += 1
return new LatexParser(content, this.state).parse()
}
}
this.cursor += 1
}
this.state.valid = false
return null
}
private rawGroup(): string | null {
while (/\s/u.test(this.source[this.cursor] ?? "")) this.cursor += 1
if (this.source[this.cursor] !== "{") {
this.state.valid = false
return null
}
const start = this.cursor + 1
let depth = 1
this.cursor += 1
while (this.cursor < this.source.length) {
const value = this.source[this.cursor]
if (value === "\\") {
this.cursor += Math.min(2, this.source.length - this.cursor)
continue
}
if (value === "{") depth += 1
else if (value === "}") {
depth -= 1
if (depth === 0) {
const content = this.source.slice(start, this.cursor)
this.cursor += 1
return content
}
}
this.cursor += 1
}
this.state.valid = false
return null
}
}
function plainText(source: string, state: ParseState): string {
let text = ""
let cursor = 0
while (cursor < source.length) {
if (source[cursor] !== "\\") {
text += source[cursor]
cursor += 1
continue
}
const next = source[cursor + 1]
if (next === undefined) break
if (!/[A-Za-z]/u.test(next)) {
text += ",:;! ".includes(next) ? " " : next
cursor += 2
continue
}
let end = cursor + 1
while (/[A-Za-z]/u.test(source[end] ?? "")) end += 1
const name = source.slice(cursor + 1, end)
const symbol = MATH_SYMBOLS[name]
if (symbol !== undefined) text += symbol
else if (NAMED_FUNCTIONS.has(name)) text += name
else {
state.valid = false
text += `\\${name}`
}
cursor = end
}
return text.replace(/\s+/gu, " ").trim()
}
function script(value: string, alphabet: Readonly<Record<string, string>>, marker: string): string {
const normalized = normalizeMath(value).replace(/\s+/gu, "")
const converted = [...normalized].map((character) => alphabet[character])
if (converted.every((character) => character !== undefined)) return converted.join("")
return normalized ? `${marker}(${normalized})` : marker
}
function fractionSide(value: string): string {
const normalized = normalizeMath(value)
const compound = /[=+×÷·±]/u.test(normalized)
|| /[-]/u.test(normalized.slice(1))
|| /\s\/\s/u.test(normalized)
return compound ? `(${normalized})` : normalized
}
function normalizeMath(value: string): string {
return value
.split("\n")
.map((line) => line
.replace(/[ \t]+/gu, " ")
.trim()
.replace(/\s*([=×÷±])\s*/gu, " $1 ")
.replace(/\s*\/\s*/gu, " / ")
.replace(/\(\s+/gu, "(")
.replace(/\s+\)/gu, ")")
.replace(/\s+([,;:%])/gu, "$1")
.replace(/[ \t]+/gu, " ")
.trim())
.filter(Boolean)
.join("\n")
}
function escapeMarkdown(value: string): string {
return value
.replace(/\\/gu, "\\\\")
.replace(/([`*_{}\[\]<>#|~])/gu, "\\$1")
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
} from "@opentui/core"
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
import { renderLatexAsUnicode } from "./latex"
import { hideScrollbars } from "./scrollbox"
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
@@ -255,7 +256,7 @@ export class Transcript {
}
if (!this.live.content && !this.pendingStream) {
this.live.content = delta
this.live.markdown.content = delta
this.live.markdown.content = renderLatexAsUnicode(delta)
return
}
this.pendingStream += delta
@@ -269,7 +270,7 @@ export class Transcript {
const content = fallback || this.live.content
// Finalize the retained Markdown node in place. This preserves scroll
// anchors and avoids the one-frame jump caused by replacing the row.
this.live.markdown.content = content
this.live.markdown.content = renderLatexAsUnicode(content)
this.live.markdown.streaming = false
this.live = null
} else if (fallback.trim()) {
@@ -282,7 +283,7 @@ export class Transcript {
this.clearStreamTimer()
this.pendingStream = ""
this.live.content = content
this.live.markdown.content = content
this.live.markdown.content = renderLatexAsUnicode(content)
}
progress(content: string, events: ToolProgressEvent[] = []): string {
@@ -342,7 +343,7 @@ export class Transcript {
if (!this.live || !this.pendingStream) return
this.live.content += this.pendingStream
this.pendingStream = ""
this.live.markdown.content = this.live.content
this.live.markdown.content = renderLatexAsUnicode(this.live.content)
}
private clearStreamTimer(): void {
@@ -512,7 +513,7 @@ export class Transcript {
private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable {
const markdown = new MarkdownRenderable(this.renderer, {
id: this.id(id),
content,
content: renderLatexAsUnicode(content),
width: "auto",
minWidth: 0,
flexGrow: 1,