Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentui/core": "0.5.11",
"arktype": "catalog:",
"highlight.js": "^11.11.1",
"isomorphic-git": "catalog:"
},
"devDependencies": {
Expand Down
19 changes: 9 additions & 10 deletions src/tui/markdown-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
withholdIncompleteHeading,
type StyledSegment,
} from "./markdown-parser.js";
import { color } from "./semantic-theme.js";

function firstLine(text: string): StyledSegment[] {
return parseMarkdown(text)[0] ?? [];
Expand Down Expand Up @@ -124,19 +123,19 @@ describe("block elements", () => {
expect(lines[0]?.[0]?.text).toContain("╭");
});

test("highlights a fenced block by its language token", () => {
test("a fenced block keeps its language cap with a plain body", () => {
const lines = parseMarkdown('```js\nconst x = "hi";\n```');
const keyword = lines.flat().find((s) => s.text === "const");
expect(keyword?.code).toBe(true);
expect(keyword?.color).toBe(color("syntaxKeyword"));
const str = lines.flat().find((s) => s.text === '"hi"');
expect(str?.color).toBe(color("syntaxString"));
const body = lines.flat().find((s) => s.text.includes("const x"));
expect(body?.code).toBe(true);
expect(body?.color).toBeUndefined();
expect(lines[0]?.map((s) => s.text).join("")).toContain("js");
});

test("an unclosed streaming fence still highlights its body", () => {
test("an unclosed streaming fence keeps a plain body", () => {
const lines = parseMarkdown("```js\nconst x = 1;");
const keyword = lines.flat().find((s) => s.text === "const");
expect(keyword?.color).toBe(color("syntaxKeyword"));
const body = lines.flat().find((s) => s.text.includes("const x"));
expect(body?.code).toBe(true);
expect(body?.color).toBeUndefined();
});

test("drops a half-typed closing fence from the streaming tail", () => {
Expand Down
282 changes: 227 additions & 55 deletions src/tui/syntax-highlight.test.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,243 @@
/**
* Fenced-code highlighting after the highlight.js → tree-sitter swap.
*
* Colour lives in the native renderer: a fenced block paints through
* MarkdownRenderable with transcriptSyntaxStyle() over the bundled
* tree-sitter grammars (javascript, typescript, markdown, zig). The
* synchronous StyledSegment model keeps geometry only — plain code segments
* cached by width. These tests pin both halves: the plain sync fallback and
* the supported/unsupported language parity the native renderer applies.
*/

import { describe, expect, test } from "bun:test";
import type { StyledSegment } from "./markdown-parser.js";
import { MarkdownRenderable, RGBA, type CapturedSpan } from "@opentui/core";
import { withTestRenderer, type Harness } from "./harness";
import { appendStreamRow } from "./shell/chrome.js";
import { createAppShell } from "./shell/index.js";
import { transcriptSyntaxStyle } from "./stream";
import { highlightCode } from "./syntax-highlight.js";
import { color } from "./semantic-theme.js";

function segmentFor(
lines: StyledSegment[][],
text: string,
): StyledSegment | undefined {
return lines.flat().find((seg) => seg.text === text);
}
import { UI } from "./theme";

describe("highlightCode", () => {
test("colours keywords, strings, and comments from the theme tokens", () => {
const lines = highlightCode('const x = "hi"; // note', "javascript", 80);
expect(segmentFor(lines, "const")?.color).toBe(color("syntaxKeyword"));
expect(segmentFor(lines, '"hi"')?.color).toBe(color("syntaxString"));
expect(segmentFor(lines, "// note")?.color).toBe(color("syntaxComment"));
});

test("colours numbers from the syntaxNumber token", () => {
const lines = highlightCode("let n = 42;", "javascript", 80);
expect(segmentFor(lines, "42")?.color).toBe(color("syntaxNumber"));
test("segments every line as plain code with no colours", () => {
expect(highlightCode('const x = "hi";\n// yo', "javascript", 80)).toEqual([
[{ text: 'const x = "hi";', code: true }],
[{ text: "// yo", code: true }],
]);
});

test("every highlighted segment is marked as code", () => {
test("the cache is keyed by width", () => {
const lines = highlightCode("const x = 1;", "javascript", 80);
for (const seg of lines.flat()) expect(seg.code).toBe(true);
expect(highlightCode("const x = 1;", "javascript", 80)).toBe(lines);
expect(highlightCode("const x = 1;", "javascript", 81)).not.toBe(lines);
expect(highlightCode("const x = 1;", "javascript", 81)).toEqual(lines);
});
});

test("preserves line count so wrapping stays aligned", () => {
const code = "line one\nline two\nline three";
expect(highlightCode(code, "javascript", 80)).toHaveLength(3);
});
const HEADING_FG = RGBA.fromHex(UI.heading);
const KEYWORD_FG = RGBA.fromHex(UI.inFlightBright);
const STRING_FG = RGBA.fromHex(UI.done);
const COMMENT_FG = RGBA.fromHex(UI.textFaint);
const FUNCTION_FG = RGBA.fromHex(UI.inFlight);
const SYNTAX_FGS = [KEYWORD_FG, STRING_FG, COMMENT_FG, FUNCTION_FG];

test("decodes HTML entities back to their source characters", () => {
const lines = highlightCode("const ok = a > b && c < d;", "javascript", 80);
const rendered = lines
.flat()
.map((s) => s.text)
.join("");
expect(rendered).toContain(">");
expect(rendered).toContain("<");
expect(rendered).toContain("&&");
});
function renderFence(h: Harness, fence: string): void {
h.root.add(
new MarkdownRenderable(h.renderer, {
syntaxStyle: transcriptSyntaxStyle(),
fg: UI.text,
width: 80,
flexShrink: 0,
content: fence,
streaming: false,
}),
);
}

test("falls back to uncoloured code for an unknown language", () => {
const lines = highlightCode("some plain text", "not-a-language", 80);
for (const seg of lines.flat()) {
expect(seg.code).toBe(true);
expect(seg.color).toBeUndefined();
}
});
async function settleSpans(
h: Harness,
ready: (spans: CapturedSpan[]) => boolean,
): Promise<CapturedSpan[]> {
let spans = h.captureSpans().lines.flatMap((line) => line.spans);
for (let i = 0; i < 400 && !ready(spans); i++) {
await h.renderOnce();
await new Promise((resolve) => setTimeout(resolve, 25));
spans = h.captureSpans().lines.flatMap((line) => line.spans);
}
return spans;
}

test("falls back to uncoloured code when the language is absent", () => {
const lines = highlightCode("plain fenced text", undefined, 80);
expect(segmentFor(lines, "plain fenced text")?.color).toBeUndefined();
});
function coloured(spans: CapturedSpan[], text: string, fg: RGBA): boolean {
return spans.some((s) => s.text.includes(text) && s.fg.equals(fg));
}

test("caches identical (text, language, width) requests", () => {
const first = highlightCode("const cached = 1;", "javascript", 80);
const second = highlightCode("const cached = 1;", "javascript", 80);
expect(second).toBe(first);
});
function hasSyntaxColour(spans: CapturedSpan[], text: string): boolean {
return spans.some(
(span) =>
span.text.includes(text) && SYNTAX_FGS.some((fg) => span.fg.equals(fg)),
);
}

test("keys the cache on width so a resize does not reuse stale output", () => {
const narrow = highlightCode("const w = 1;", "javascript", 40);
const wide = highlightCode("const w = 1;", "javascript", 120);
expect(wide).not.toBe(narrow);
async function expectTranscriptHighlighting(
h: Harness,
supportedLanguage: "js" | "ts",
streaming: boolean,
): Promise<void> {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 40 },
wireKeys: false,
});
try {
appendStreamRow(shell, {
role: "assistant",
streaming,
text: [
`\`\`\`${supportedLanguage}`,
"const supported_route = 42;",
"```",
"",
"```python",
"def python_plain():",
' return "visible"',
"```",
].join("\n"),
});
const spans = await settleSpans(
h,
(current) =>
coloured(current, "const", KEYWORD_FG) &&
current.some((span) => span.text.includes("python_plain")),
);

expect(coloured(spans, "const", KEYWORD_FG)).toBe(true);
expect(h.captureCharFrame()).toContain("python_plain");
expect(hasSyntaxColour(spans, "python_plain")).toBe(false);
} finally {
shell.dispose();
}
}

describe("production transcript fenced rendering", () => {
test("settled assistant fences use production syntax ownership", async () => {
await withTestRenderer(
(h) => expectTranscriptHighlighting(h, "js", false),
{ width: 80, height: 40 },
);
}, 30000);

test("streaming assistant fences use production syntax ownership", async () => {
await withTestRenderer((h) => expectTranscriptHighlighting(h, "ts", true), {
width: 80,
height: 40,
});
}, 30000);
});

describe("native fenced rendering", () => {
test("a js fence colours keywords, strings, comments, and numbers", async () => {
await withTestRenderer(
async (h) => {
renderFence(
h,
'```js\nconst greeting = "hi"; // yo\nconst n = 42;\n```',
);
const spans = await settleSpans(
h,
(s) =>
coloured(s, "const", KEYWORD_FG) &&
coloured(s, "hi", STRING_FG) &&
coloured(s, "// yo", COMMENT_FG) &&
coloured(s, "42", STRING_FG),
);
expect(coloured(spans, "const", KEYWORD_FG)).toBe(true);
expect(coloured(spans, "hi", STRING_FG)).toBe(true);
expect(coloured(spans, "// yo", COMMENT_FG)).toBe(true);
expect(coloured(spans, "42", STRING_FG)).toBe(true);
},
{ width: 80, height: 24 },
);
}, 30000);

test("a ts fence colours keywords and numbers", async () => {
await withTestRenderer(
async (h) => {
renderFence(h, "```ts\nconst n: number = 42;\n```");
const spans = await settleSpans(
h,
(s) =>
coloured(s, "const", KEYWORD_FG) && coloured(s, "42", STRING_FG),
);
expect(coloured(spans, "const", KEYWORD_FG)).toBe(true);
expect(coloured(spans, "42", STRING_FG)).toBe(true);
},
{ width: 80, height: 24 },
);
}, 30000);

test("a markdown fence colours headings", async () => {
await withTestRenderer(
async (h) => {
renderFence(h, "```markdown\n# heading\n```");
const spans = await settleSpans(h, (s) =>
coloured(s, "heading", HEADING_FG),
);
expect(coloured(spans, "heading", HEADING_FG)).toBe(true);
},
{ width: 80, height: 24 },
);
}, 30000);

test("jsx and tsx fences highlight through their grammar aliases", async () => {
for (const fence of [
"```jsx\nconst el = <div />;\n```",
"```tsx\nconst el = <div />;\n```",
]) {
await withTestRenderer(
async (h) => {
renderFence(h, fence);
const spans = await settleSpans(h, (s) =>
coloured(s, "const", KEYWORD_FG),
);
expect(coloured(spans, "const", KEYWORD_FG)).toBe(true);
},
{ width: 80, height: 24 },
);
}
}, 30000);

test("a zig fence colours keywords", async () => {
await withTestRenderer(
async (h) => {
renderFence(h, "```zig\nconst x: i32 = 1;\n```");
const spans = await settleSpans(h, (s) =>
coloured(s, "const", KEYWORD_FG),
);
expect(coloured(spans, "const", KEYWORD_FG)).toBe(true);
},
{ width: 80, height: 24 },
);
}, 30000);

test("python, go, and rust fences render PLAIN with no syntax colours", async () => {
const fences: [string, string][] = [
['```python\ndef greet():\n return "hi" # yo\n```', "def greet"],
["```go\npackage main\n\nfunc main() {}\n```", "func main"],
['```rust\nfn main() {\n println!("hi");\n}\n```', "fn main"],
];
for (const [fence, marker] of fences) {
await withTestRenderer(
async (h) => {
renderFence(h, fence);
const spans = await settleSpans(h, () =>
h.captureCharFrame().includes(marker),
);
expect(h.captureCharFrame()).toContain(marker);
expect(
spans.some((s) => SYNTAX_FGS.some((fg) => s.fg.equals(fg))),
).toBe(false);
},
{ width: 80, height: 24 },
);
}
}, 60000);
});
Loading
Loading