mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
* fix(tui): blockquote multiline rendering and wrapping Fix two bugs in blockquote rendering: 1. ANSI codes split incorrectly on newlines - when text tokens contain newlines, applyDefaultStyle() wrapped the entire string including the newline in ANSI codes. Any caller splitting by \n would break the codes. Fixed by splitting text by newlines before styling in renderInlineTokens(). 2. Wrapped blockquote lines lost the │ border - long blockquote lines that wrapped to multiple lines only had the border on the first line. Fixed by wrapping within the blockquote case and adding border to each line. * test(tui): add test for inline formatting inside blockquotes
934 lines
32 KiB
TypeScript
934 lines
32 KiB
TypeScript
import assert from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
|
import { Chalk } from "chalk";
|
|
import { Markdown } from "../src/components/markdown.js";
|
|
import { type Component, TUI } from "../src/tui.js";
|
|
import { defaultMarkdownTheme } from "./test-themes.js";
|
|
import { VirtualTerminal } from "./virtual-terminal.js";
|
|
|
|
// Force full color in CI so ANSI assertions are deterministic
|
|
const chalk = new Chalk({ level: 3 });
|
|
|
|
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
|
|
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
|
const buffer = xterm.buffer.active;
|
|
const line = buffer.getLine(buffer.viewportY + row);
|
|
assert.ok(line, `Missing buffer line at row ${row}`);
|
|
const cell = line.getCell(col);
|
|
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
|
return cell.isItalic();
|
|
}
|
|
|
|
describe("Markdown component", () => {
|
|
describe("Nested lists", () => {
|
|
it("should render simple nested list", () => {
|
|
const markdown = new Markdown(
|
|
`- Item 1
|
|
- Nested 1.1
|
|
- Nested 1.2
|
|
- Item 2`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
|
|
// Check that we have content
|
|
assert.ok(lines.length > 0);
|
|
|
|
// Strip ANSI codes for checking
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Check structure
|
|
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
|
|
assert.ok(plainLines.some((line) => line.includes("- Item 2")));
|
|
});
|
|
|
|
it("should render deeply nested list", () => {
|
|
const markdown = new Markdown(
|
|
`- Level 1
|
|
- Level 2
|
|
- Level 3
|
|
- Level 4`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Check proper indentation
|
|
assert.ok(plainLines.some((line) => line.includes("- Level 1")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Level 2")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Level 3")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Level 4")));
|
|
});
|
|
|
|
it("should render ordered nested list", () => {
|
|
const markdown = new Markdown(
|
|
`1. First
|
|
1. Nested first
|
|
2. Nested second
|
|
2. Second`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
assert.ok(plainLines.some((line) => line.includes("1. First")));
|
|
assert.ok(plainLines.some((line) => line.includes(" 1. Nested first")));
|
|
assert.ok(plainLines.some((line) => line.includes(" 2. Nested second")));
|
|
assert.ok(plainLines.some((line) => line.includes("2. Second")));
|
|
});
|
|
|
|
it("should render mixed ordered and unordered nested lists", () => {
|
|
const markdown = new Markdown(
|
|
`1. Ordered item
|
|
- Unordered nested
|
|
- Another nested
|
|
2. Second ordered
|
|
- More nested`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
assert.ok(plainLines.some((line) => line.includes("1. Ordered item")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Unordered nested")));
|
|
assert.ok(plainLines.some((line) => line.includes("2. Second ordered")));
|
|
});
|
|
|
|
it("should maintain numbering when code blocks are not indented (LLM output)", () => {
|
|
// When code blocks aren't indented, marked parses each item as a separate list.
|
|
// We use token.start to preserve the original numbering.
|
|
const markdown = new Markdown(
|
|
`1. First item
|
|
|
|
\`\`\`typescript
|
|
// code block
|
|
\`\`\`
|
|
|
|
2. Second item
|
|
|
|
\`\`\`typescript
|
|
// another code block
|
|
\`\`\`
|
|
|
|
3. Third item`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim());
|
|
|
|
// Find all lines that start with a number and period
|
|
const numberedLines = plainLines.filter((line) => /^\d+\./.test(line));
|
|
|
|
// Should have 3 numbered items
|
|
assert.strictEqual(numberedLines.length, 3, `Expected 3 numbered items, got: ${numberedLines.join(", ")}`);
|
|
|
|
// Check the actual numbers
|
|
assert.ok(numberedLines[0].startsWith("1."), `First item should be "1.", got: ${numberedLines[0]}`);
|
|
assert.ok(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`);
|
|
assert.ok(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`);
|
|
});
|
|
});
|
|
|
|
describe("Tables", () => {
|
|
it("should render simple table", () => {
|
|
const markdown = new Markdown(
|
|
`| Name | Age |
|
|
| --- | --- |
|
|
| Alice | 30 |
|
|
| Bob | 25 |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Check table structure
|
|
assert.ok(plainLines.some((line) => line.includes("Name")));
|
|
assert.ok(plainLines.some((line) => line.includes("Age")));
|
|
assert.ok(plainLines.some((line) => line.includes("Alice")));
|
|
assert.ok(plainLines.some((line) => line.includes("Bob")));
|
|
// Check for table borders
|
|
assert.ok(plainLines.some((line) => line.includes("│")));
|
|
assert.ok(plainLines.some((line) => line.includes("─")));
|
|
});
|
|
|
|
it("should render row dividers between data rows", () => {
|
|
const markdown = new Markdown(
|
|
`| Name | Age |
|
|
| --- | --- |
|
|
| Alice | 30 |
|
|
| Bob | 25 |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const dividerLines = plainLines.filter((line) => line.includes("┼"));
|
|
|
|
assert.strictEqual(dividerLines.length, 2, "Expected header + row divider");
|
|
});
|
|
|
|
it("should keep column width at least the longest word", () => {
|
|
const longestWord = "superlongword";
|
|
const markdown = new Markdown(
|
|
`| Column One | Column Two |
|
|
| --- | --- |
|
|
| ${longestWord} short | otherword |
|
|
| small | tiny |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(32);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const dataLine = plainLines.find((line) => line.includes(longestWord));
|
|
assert.ok(dataLine, "Expected data row containing longest word");
|
|
|
|
const segments = dataLine.split("│").slice(1, -1);
|
|
const [firstSegment] = segments;
|
|
assert.ok(firstSegment, "Expected first column segment");
|
|
const firstColumnWidth = firstSegment.length - 2;
|
|
|
|
assert.ok(
|
|
firstColumnWidth >= longestWord.length,
|
|
`Expected first column width >= ${longestWord.length}, got ${firstColumnWidth}`,
|
|
);
|
|
});
|
|
|
|
it("should render table with alignment", () => {
|
|
const markdown = new Markdown(
|
|
`| Left | Center | Right |
|
|
| :--- | :---: | ---: |
|
|
| A | B | C |
|
|
| Long text | Middle | End |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Check headers
|
|
assert.ok(plainLines.some((line) => line.includes("Left")));
|
|
assert.ok(plainLines.some((line) => line.includes("Center")));
|
|
assert.ok(plainLines.some((line) => line.includes("Right")));
|
|
// Check content
|
|
assert.ok(plainLines.some((line) => line.includes("Long text")));
|
|
});
|
|
|
|
it("should handle tables with varying column widths", () => {
|
|
const markdown = new Markdown(
|
|
`| Short | Very long column header |
|
|
| --- | --- |
|
|
| A | This is a much longer cell content |
|
|
| B | Short |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
|
|
// Should render without errors
|
|
assert.ok(lines.length > 0);
|
|
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
assert.ok(plainLines.some((line) => line.includes("Very long column header")));
|
|
assert.ok(plainLines.some((line) => line.includes("This is a much longer cell content")));
|
|
});
|
|
|
|
it("should wrap table cells when table exceeds available width", () => {
|
|
const markdown = new Markdown(
|
|
`| Command | Description | Example |
|
|
| --- | --- | --- |
|
|
| npm install | Install all dependencies | npm install |
|
|
| npm run build | Build the project | npm run build |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
// Render at narrow width that forces wrapping
|
|
const lines = markdown.render(50);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// All lines should fit within width
|
|
for (const line of plainLines) {
|
|
assert.ok(line.length <= 50, `Line exceeds width 50: "${line}" (length: ${line.length})`);
|
|
}
|
|
|
|
// Content should still be present (possibly wrapped across lines)
|
|
const allText = plainLines.join(" ");
|
|
assert.ok(allText.includes("Command"), "Should contain 'Command'");
|
|
assert.ok(allText.includes("Description"), "Should contain 'Description'");
|
|
assert.ok(allText.includes("npm install"), "Should contain 'npm install'");
|
|
assert.ok(allText.includes("Install"), "Should contain 'Install'");
|
|
});
|
|
|
|
it("should wrap long cell content to multiple lines", () => {
|
|
const markdown = new Markdown(
|
|
`| Header |
|
|
| --- |
|
|
| This is a very long cell content that should wrap |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
// Render at width that forces the cell to wrap
|
|
const lines = markdown.render(25);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// Should have multiple data rows due to wrapping
|
|
const dataRows = plainLines.filter((line) => line.startsWith("│") && !line.includes("─"));
|
|
assert.ok(dataRows.length > 2, `Expected wrapped rows, got ${dataRows.length} rows`);
|
|
|
|
// All content should be preserved (may be split across lines)
|
|
const allText = plainLines.join(" ");
|
|
assert.ok(allText.includes("very long"), "Should preserve 'very long'");
|
|
assert.ok(allText.includes("cell content"), "Should preserve 'cell content'");
|
|
assert.ok(allText.includes("should wrap"), "Should preserve 'should wrap'");
|
|
});
|
|
|
|
it("should wrap long unbroken tokens inside table cells (not only at line start)", () => {
|
|
const url = "https://example.com/this/is/a/very/long/url/that/should/wrap";
|
|
const markdown = new Markdown(
|
|
`| Value |
|
|
| --- |
|
|
| prefix ${url} |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const width = 30;
|
|
const lines = markdown.render(width);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
for (const line of plainLines) {
|
|
assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`);
|
|
}
|
|
|
|
// Borders should stay intact (exactly 2 vertical borders for a 1-col table)
|
|
const tableLines = plainLines.filter((line) => line.startsWith("│"));
|
|
assert.ok(tableLines.length > 0, "Expected table rows to render");
|
|
for (const line of tableLines) {
|
|
const borderCount = line.split("│").length - 1;
|
|
assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`);
|
|
}
|
|
|
|
// Strip box drawing characters + whitespace so we can assert the URL is preserved
|
|
// even if it was split across multiple wrapped lines.
|
|
const extracted = plainLines.join("").replace(/[│├┤─\s]/g, "");
|
|
assert.ok(extracted.includes("prefix"), "Should preserve 'prefix'");
|
|
assert.ok(extracted.includes(url), "Should preserve URL");
|
|
});
|
|
|
|
it("should wrap styled inline code inside table cells without breaking borders", () => {
|
|
const markdown = new Markdown(
|
|
`| Code |
|
|
| --- |
|
|
| \`averyveryveryverylongidentifier\` |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const width = 20;
|
|
const lines = markdown.render(width);
|
|
const joinedOutput = lines.join("\n");
|
|
assert.ok(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)");
|
|
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
for (const line of plainLines) {
|
|
assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`);
|
|
}
|
|
|
|
const tableLines = plainLines.filter((line) => line.startsWith("│"));
|
|
for (const line of tableLines) {
|
|
const borderCount = line.split("│").length - 1;
|
|
assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`);
|
|
}
|
|
});
|
|
|
|
it("should handle extremely narrow width gracefully", () => {
|
|
const markdown = new Markdown(
|
|
`| A | B | C |
|
|
| --- | --- | --- |
|
|
| 1 | 2 | 3 |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
// Very narrow width
|
|
const lines = markdown.render(15);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// Should not crash and should produce output
|
|
assert.ok(lines.length > 0, "Should produce output");
|
|
|
|
// Lines should not exceed width
|
|
for (const line of plainLines) {
|
|
assert.ok(line.length <= 15, `Line exceeds width 15: "${line}" (length: ${line.length})`);
|
|
}
|
|
});
|
|
|
|
it("should render table correctly when it fits naturally", () => {
|
|
const markdown = new Markdown(
|
|
`| A | B |
|
|
| --- | --- |
|
|
| 1 | 2 |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
// Wide width where table fits naturally
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// Should have proper table structure
|
|
const headerLine = plainLines.find((line) => line.includes("A") && line.includes("B"));
|
|
assert.ok(headerLine, "Should have header row");
|
|
assert.ok(headerLine?.includes("│"), "Header should have borders");
|
|
|
|
const separatorLine = plainLines.find((line) => line.includes("├") && line.includes("┼"));
|
|
assert.ok(separatorLine, "Should have separator row");
|
|
|
|
const dataLine = plainLines.find((line) => line.includes("1") && line.includes("2"));
|
|
assert.ok(dataLine, "Should have data row");
|
|
});
|
|
|
|
it("should respect paddingX when calculating table width", () => {
|
|
const markdown = new Markdown(
|
|
`| Column One | Column Two |
|
|
| --- | --- |
|
|
| Data 1 | Data 2 |`,
|
|
2, // paddingX = 2
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
// Width 40 with paddingX=2 means contentWidth=36
|
|
const lines = markdown.render(40);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// All lines should respect width
|
|
for (const line of plainLines) {
|
|
assert.ok(line.length <= 40, `Line exceeds width 40: "${line}" (length: ${line.length})`);
|
|
}
|
|
|
|
// Table rows should have left padding
|
|
const tableRow = plainLines.find((line) => line.includes("│"));
|
|
assert.ok(tableRow?.startsWith(" "), "Table should have left padding");
|
|
});
|
|
});
|
|
|
|
describe("Combined features", () => {
|
|
it("should render lists and tables together", () => {
|
|
const markdown = new Markdown(
|
|
`# Test Document
|
|
|
|
- Item 1
|
|
- Nested item
|
|
- Item 2
|
|
|
|
| Col1 | Col2 |
|
|
| --- | --- |
|
|
| A | B |`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Check heading
|
|
assert.ok(plainLines.some((line) => line.includes("Test Document")));
|
|
// Check list
|
|
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
|
assert.ok(plainLines.some((line) => line.includes(" - Nested item")));
|
|
// Check table
|
|
assert.ok(plainLines.some((line) => line.includes("Col1")));
|
|
assert.ok(plainLines.some((line) => line.includes("│")));
|
|
});
|
|
});
|
|
|
|
describe("Pre-styled text (thinking traces)", () => {
|
|
it("should preserve gray italic styling after inline code", () => {
|
|
// This replicates how thinking content is rendered in assistant-message.ts
|
|
const markdown = new Markdown(
|
|
"This is thinking with `inline code` and more text after",
|
|
1,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
{
|
|
color: (text) => chalk.gray(text),
|
|
italic: true,
|
|
},
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const joinedOutput = lines.join("\n");
|
|
|
|
// Should contain the inline code block
|
|
assert.ok(joinedOutput.includes("inline code"));
|
|
|
|
// The output should have ANSI codes for gray (90) and italic (3)
|
|
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
|
|
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
|
|
|
|
// Verify that inline code is styled (theme uses yellow)
|
|
const hasCodeColor = joinedOutput.includes("\x1b[33m");
|
|
assert.ok(hasCodeColor, "Should style inline code");
|
|
});
|
|
|
|
it("should preserve gray italic styling after bold text", () => {
|
|
const markdown = new Markdown(
|
|
"This is thinking with **bold text** and more after",
|
|
1,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
{
|
|
color: (text) => chalk.gray(text),
|
|
italic: true,
|
|
},
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const joinedOutput = lines.join("\n");
|
|
|
|
// Should contain bold text
|
|
assert.ok(joinedOutput.includes("bold text"));
|
|
|
|
// The output should have ANSI codes for gray (90) and italic (3)
|
|
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
|
|
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
|
|
|
|
// Should have bold codes (1 or 22 for bold on/off)
|
|
assert.ok(joinedOutput.includes("\x1b[1m"), "Should have bold code");
|
|
});
|
|
|
|
it("should not leak styles into following lines when rendered in TUI", async () => {
|
|
class MarkdownWithInput implements Component {
|
|
public markdownLineCount = 0;
|
|
|
|
constructor(private readonly markdown: Markdown) {}
|
|
|
|
render(width: number): string[] {
|
|
const lines = this.markdown.render(width);
|
|
this.markdownLineCount = lines.length;
|
|
return [...lines, "INPUT"];
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.markdown.invalidate();
|
|
}
|
|
}
|
|
|
|
const markdown = new Markdown("This is thinking with `inline code`", 1, 0, defaultMarkdownTheme, {
|
|
color: (text) => chalk.gray(text),
|
|
italic: true,
|
|
});
|
|
|
|
const terminal = new VirtualTerminal(80, 6);
|
|
const tui = new TUI(terminal);
|
|
const component = new MarkdownWithInput(markdown);
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
assert.ok(component.markdownLineCount > 0);
|
|
const inputRow = component.markdownLineCount;
|
|
assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("Spacing after code blocks", () => {
|
|
it("should have only one blank line between code block and following paragraph", () => {
|
|
const markdown = new Markdown(
|
|
`hello world
|
|
|
|
\`\`\`js
|
|
const hello = "world";
|
|
\`\`\`
|
|
|
|
again, hello world`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
const closingBackticksIndex = plainLines.indexOf("```");
|
|
assert.ok(closingBackticksIndex !== -1, "Should have closing backticks");
|
|
|
|
const afterBackticks = plainLines.slice(closingBackticksIndex + 1);
|
|
const emptyLineCount = afterBackticks.findIndex((line) => line !== "");
|
|
|
|
assert.strictEqual(
|
|
emptyLineCount,
|
|
1,
|
|
`Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Spacing after dividers", () => {
|
|
it("should have only one blank line between divider and following paragraph", () => {
|
|
const markdown = new Markdown(
|
|
`hello world
|
|
|
|
---
|
|
|
|
again, hello world`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
const dividerIndex = plainLines.findIndex((line) => line.includes("─"));
|
|
assert.ok(dividerIndex !== -1, "Should have divider");
|
|
|
|
const afterDivider = plainLines.slice(dividerIndex + 1);
|
|
const emptyLineCount = afterDivider.findIndex((line) => line !== "");
|
|
|
|
assert.strictEqual(
|
|
emptyLineCount,
|
|
1,
|
|
`Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Spacing after headings", () => {
|
|
it("should have only one blank line between heading and following paragraph", () => {
|
|
const markdown = new Markdown(
|
|
`# Hello
|
|
|
|
This is a paragraph`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
const headingIndex = plainLines.findIndex((line) => line.includes("Hello"));
|
|
assert.ok(headingIndex !== -1, "Should have heading");
|
|
|
|
const afterHeading = plainLines.slice(headingIndex + 1);
|
|
const emptyLineCount = afterHeading.findIndex((line) => line !== "");
|
|
|
|
assert.strictEqual(
|
|
emptyLineCount,
|
|
1,
|
|
`Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Spacing after blockquotes", () => {
|
|
it("should have only one blank line between blockquote and following paragraph", () => {
|
|
const markdown = new Markdown(
|
|
`hello world
|
|
|
|
> This is a quote
|
|
|
|
again, hello world`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
const quoteIndex = plainLines.findIndex((line) => line.includes("This is a quote"));
|
|
assert.ok(quoteIndex !== -1, "Should have blockquote");
|
|
|
|
const afterQuote = plainLines.slice(quoteIndex + 1);
|
|
const emptyLineCount = afterQuote.findIndex((line) => line !== "");
|
|
|
|
assert.strictEqual(
|
|
emptyLineCount,
|
|
1,
|
|
`Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Blockquotes with multiline content", () => {
|
|
it("should apply consistent styling to all lines in lazy continuation blockquote", () => {
|
|
// Markdown "lazy continuation" - second line without > is still part of the quote
|
|
const markdown = new Markdown(
|
|
`>Foo
|
|
bar`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
{
|
|
color: (text) => chalk.magenta(text), // This should NOT be applied to blockquotes
|
|
},
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
|
|
// Both lines should have the quote border
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const quotedLines = plainLines.filter((line) => line.startsWith("│ "));
|
|
assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`);
|
|
|
|
// Both lines should have italic (from theme.quote styling)
|
|
const fooLine = lines.find((line) => line.includes("Foo"));
|
|
const barLine = lines.find((line) => line.includes("bar"));
|
|
assert.ok(fooLine, "Should have Foo line");
|
|
assert.ok(barLine, "Should have bar line");
|
|
|
|
// Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color
|
|
assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`);
|
|
assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`);
|
|
|
|
// Blockquotes should NOT have the default message color (magenta)
|
|
assert.ok(!fooLine?.includes("\x1b[35m"), `Foo line should NOT have magenta color: ${fooLine}`);
|
|
assert.ok(!barLine?.includes("\x1b[35m"), `bar line should NOT have magenta color: ${barLine}`);
|
|
});
|
|
|
|
it("should apply consistent styling to explicit multiline blockquote", () => {
|
|
const markdown = new Markdown(
|
|
`>Foo
|
|
>bar`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
{
|
|
color: (text) => chalk.cyan(text), // This should NOT be applied to blockquotes
|
|
},
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
|
|
// Both lines should have the quote border
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const quotedLines = plainLines.filter((line) => line.startsWith("│ "));
|
|
assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`);
|
|
|
|
// Both lines should have italic (from theme.quote styling)
|
|
const fooLine = lines.find((line) => line.includes("Foo"));
|
|
const barLine = lines.find((line) => line.includes("bar"));
|
|
assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`);
|
|
assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`);
|
|
|
|
// Blockquotes should NOT have the default message color (cyan)
|
|
assert.ok(!fooLine?.includes("\x1b[36m"), `Foo line should NOT have cyan color: ${fooLine}`);
|
|
assert.ok(!barLine?.includes("\x1b[36m"), `bar line should NOT have cyan color: ${barLine}`);
|
|
});
|
|
|
|
it("should wrap long blockquote lines and add border to each wrapped line", () => {
|
|
const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered";
|
|
const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme);
|
|
|
|
// Render at narrow width to force wrapping
|
|
const lines = markdown.render(30);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// Filter to non-empty lines (exclude trailing blank line after blockquote)
|
|
const contentLines = plainLines.filter((line) => line.length > 0);
|
|
|
|
// Should have multiple lines due to wrapping
|
|
assert.ok(contentLines.length > 1, `Expected multiple wrapped lines, got: ${JSON.stringify(contentLines)}`);
|
|
|
|
// Every content line should start with the quote border
|
|
for (const line of contentLines) {
|
|
assert.ok(line.startsWith("│ "), `Wrapped line should have quote border: "${line}"`);
|
|
}
|
|
|
|
// All content should be preserved
|
|
const allText = contentLines.join(" ");
|
|
assert.ok(allText.includes("very long"), "Should preserve 'very long'");
|
|
assert.ok(allText.includes("blockquote"), "Should preserve 'blockquote'");
|
|
assert.ok(allText.includes("multiple"), "Should preserve 'multiple'");
|
|
});
|
|
|
|
it("should properly indent wrapped blockquote lines with styling", () => {
|
|
const markdown = new Markdown(
|
|
"> This is styled text that is long enough to wrap",
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
{
|
|
color: (text) => chalk.yellow(text), // This should NOT be applied to blockquotes
|
|
italic: true,
|
|
},
|
|
);
|
|
|
|
const lines = markdown.render(25);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
|
|
|
// Filter to non-empty lines
|
|
const contentLines = plainLines.filter((line) => line.length > 0);
|
|
|
|
// All lines should have the quote border
|
|
for (const line of contentLines) {
|
|
assert.ok(line.startsWith("│ "), `Line should have quote border: "${line}"`);
|
|
}
|
|
|
|
// Check that italic is applied (from theme.quote)
|
|
const allOutput = lines.join("\n");
|
|
assert.ok(allOutput.includes("\x1b[3m"), "Should have italic");
|
|
|
|
// Blockquotes should NOT have the default message color (yellow)
|
|
assert.ok(!allOutput.includes("\x1b[33m"), "Should NOT have yellow color from default style");
|
|
});
|
|
|
|
it("should render inline formatting inside blockquotes and reapply quote styling after", () => {
|
|
const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
|
|
// Should have the quote border
|
|
assert.ok(
|
|
plainLines.some((line) => line.startsWith("│ ")),
|
|
"Should have quote border",
|
|
);
|
|
|
|
// Content should be preserved
|
|
const allPlain = plainLines.join(" ");
|
|
assert.ok(allPlain.includes("Quote with"), "Should preserve 'Quote with'");
|
|
assert.ok(allPlain.includes("bold"), "Should preserve 'bold'");
|
|
assert.ok(allPlain.includes("code"), "Should preserve 'code'");
|
|
|
|
const allOutput = lines.join("\n");
|
|
|
|
// Should have bold styling (\x1b[1m)
|
|
assert.ok(allOutput.includes("\x1b[1m"), "Should have bold styling");
|
|
|
|
// Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme)
|
|
assert.ok(allOutput.includes("\x1b[33m"), "Should have code styling (yellow)");
|
|
|
|
// Should have italic from quote styling (\x1b[3m)
|
|
assert.ok(allOutput.includes("\x1b[3m"), "Should have italic from quote styling");
|
|
});
|
|
});
|
|
|
|
describe("Links", () => {
|
|
it("should not duplicate URL for autolinked emails", () => {
|
|
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join(" ");
|
|
|
|
// Should contain the email once, not duplicated with mailto:
|
|
assert.ok(joinedPlain.includes("user@example.com"), "Should contain email");
|
|
assert.ok(!joinedPlain.includes("mailto:"), "Should not show mailto: prefix for autolinked emails");
|
|
});
|
|
|
|
it("should not duplicate URL for bare URLs", () => {
|
|
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join(" ");
|
|
|
|
// URL should appear only once
|
|
const urlCount = (joinedPlain.match(/https:\/\/example\.com/g) || []).length;
|
|
assert.strictEqual(urlCount, 1, "URL should appear exactly once");
|
|
});
|
|
|
|
it("should show URL for explicit markdown links with different text", () => {
|
|
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join(" ");
|
|
|
|
// Should show both link text and URL
|
|
assert.ok(joinedPlain.includes("click here"), "Should contain link text");
|
|
assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses");
|
|
});
|
|
|
|
it("should show URL for explicit mailto links with different text", () => {
|
|
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join(" ");
|
|
|
|
// Should show both link text and mailto URL
|
|
assert.ok(joinedPlain.includes("Email me"), "Should contain link text");
|
|
assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses");
|
|
});
|
|
});
|
|
|
|
describe("HTML-like tags in text", () => {
|
|
it("should render content with HTML-like tags as text", () => {
|
|
// When the model emits something like <thinking>content</thinking> in regular text,
|
|
// marked might treat it as HTML and hide the content
|
|
const markdown = new Markdown(
|
|
"This is text with <thinking>hidden content</thinking> that should be visible",
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join(" ");
|
|
|
|
// The content inside the tags should be visible
|
|
assert.ok(
|
|
joinedPlain.includes("hidden content") || joinedPlain.includes("<thinking>"),
|
|
"Should render HTML-like tags or their content as text, not hide them",
|
|
);
|
|
});
|
|
|
|
it("should render HTML tags in code blocks correctly", () => {
|
|
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0, defaultMarkdownTheme);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
const joinedPlain = plainLines.join("\n");
|
|
|
|
// HTML in code blocks should be visible
|
|
assert.ok(
|
|
joinedPlain.includes("<div>") && joinedPlain.includes("</div>"),
|
|
"Should render HTML in code blocks",
|
|
);
|
|
});
|
|
});
|
|
});
|