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
+4
View File
@@ -13,6 +13,10 @@ bun run --cwd tui build
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as
Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and
LaTeX inside inline or fenced code remain literal.
## Herdr host mode
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands.
+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,