mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): render LaTeX as Unicode
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user