mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 20:03:08 +00:00
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1223 lines
36 KiB
TypeScript
1223 lines
36 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 render list content inside blockquotes", () => {
|
|
const markdown = new Markdown(
|
|
`> 1. bla bla
|
|
> - nested bullet`,
|
|
0,
|
|
0,
|
|
defaultMarkdownTheme,
|
|
);
|
|
|
|
const lines = markdown.render(80);
|
|
const plainLines = lines.map((line) =>
|
|
line.replace(/\x1b\[[0-9;]*m/g, ""),
|
|
);
|
|
const quotedLines = plainLines.filter((line) => line.startsWith("│ "));
|
|
|
|
assert.ok(
|
|
quotedLines.some((line) => line.includes("1. bla bla")),
|
|
`Missing ordered list item: ${JSON.stringify(quotedLines)}`,
|
|
);
|
|
assert.ok(
|
|
quotedLines.some((line) => line.includes("- nested bullet")),
|
|
`Missing unordered list item: ${JSON.stringify(quotedLines)}`,
|
|
);
|
|
});
|
|
|
|
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",
|
|
);
|
|
});
|
|
});
|
|
});
|