Skip to content
Merged
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
64 changes: 44 additions & 20 deletions packages/tui/src/components/markdown/list-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export interface ListRenderContext {
) => string;
}

/**
* A rendered list-item line, tagged with whether it came from a nested list
* (nested-list lines already carry their own full indentation).
*/
interface ListItemLine {
text: string;
nested: boolean;
}

/**
* Render a list with proper nesting support.
*
Expand All @@ -44,33 +53,30 @@ export function renderList(
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";

// Process item tokens to handle nested lists
const itemLines = renderListItem(item.tokens || [], depth, context);
const itemLines = renderListItemLines(item.tokens || [], depth, context);

if (itemLines.length > 0) {
// First line - check if it's a nested list
// A nested list will start with indent (spaces) followed by cyan bullet
// First line - nested-list lines are already fully indented
const firstLine = itemLines[0];
const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char

if (isNestedList) {
if (firstLine.nested) {
// This is a nested list, just add it as-is (already has full indent)
lines.push(firstLine);
lines.push(firstLine.text);
} else {
// Regular text content - add indent and bullet
lines.push(indent + context.theme.listBullet(bullet) + firstLine);
lines.push(indent + context.theme.listBullet(bullet) + firstLine.text);
}

// Rest of the lines
for (let j = 1; j < itemLines.length; j++) {
const line = itemLines[j];
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char

if (isNestedListLine) {
if (line.nested) {
// Nested list line - already has full indent
lines.push(line);
lines.push(line.text);
} else {
// Regular content - add parent indent + 2 spaces for continuation
lines.push(`${indent} ${line}`);
lines.push(`${indent} ${line.text}`);
}
}
} else {
Expand All @@ -95,7 +101,23 @@ export function renderListItem(
parentDepth: number,
context: ListRenderContext,
): string[] {
const lines: string[] = [];
return renderListItemLines(tokens, parentDepth, context).map(
(line) => line.text,
);
}

/**
* Render list item tokens to lines tagged with nested-list provenance, so
* renderList() can tell nested-list lines (already fully indented) apart from
* regular content without sniffing for theme-specific ANSI color codes.
*/
function renderListItemLines(
tokens: Token[],
parentDepth: number,
context: ListRenderContext,
): ListItemLine[] {
const lines: ListItemLine[] = [];
const push = (text: string) => lines.push({ text, nested: false });

for (const token of tokens) {
if (token.type === "list") {
Expand All @@ -106,7 +128,9 @@ export function renderListItem(
parentDepth + 1,
context,
);
lines.push(...nestedLines);
for (const nestedLine of nestedLines) {
lines.push({ text: nestedLine, nested: true });
}
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
Expand All @@ -115,35 +139,35 @@ export function renderListItem(
: "text" in token && typeof token.text === "string"
? token.text
: "";
lines.push(text);
push(text);
} else if (token.type === "paragraph") {
// Paragraph in list item
const text = context.renderInlineTokens(token.tokens || []);
lines.push(text);
push(text);
} else if (token.type === "code") {
// Code block in list item
const indent = context.theme.codeBlockIndent ?? " ";
lines.push(context.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
push(context.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (context.theme.highlightCode) {
const highlightedLines = context.theme.highlightCode(
token.text,
token.lang,
);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
push(`${indent}${hlLine}`);
}
} else {
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${context.theme.codeBlock(codeLine)}`);
push(`${indent}${context.theme.codeBlock(codeLine)}`);
}
}
lines.push(context.theme.codeBlockBorder("```"));
push(context.theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = context.renderInlineTokens([token]);
if (text) {
lines.push(text);
push(text);
}
}
}
Expand Down
11 changes: 9 additions & 2 deletions packages/tui/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,15 @@ function rawCtrlChar(key: string): string | null {
function parseKeyId(
keyId: string,
): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null {
const parts = keyId.toLowerCase().split("+");
const key = parts[parts.length - 1];
const lower = keyId.toLowerCase();
const parts = lower.split("+");
// "+" is both the modifier separator and a valid base key: "+", "ctrl++",
// "shift+alt++" etc. split() then yields a trailing empty part, so a keyId
// that ends with "+" means the base key is the literal "+" symbol.
let key = parts[parts.length - 1];
if (!key && lower.endsWith("+")) {
key = "+";
}
if (!key) return null;
return {
key,
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function couldBeEmoji(segment: string): boolean {
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
segment.includes("\u200D") // ZWJ sequences (family, profession emoji, etc.)
);
}

Expand Down
29 changes: 29 additions & 0 deletions packages/tui/test/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,33 @@ describe("parseKey", () => {
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
});
});

describe("plus symbol key", () => {
// "+" is both the modifier separator and a valid base key, so keyIds
// like "+" and "ctrl++" end with a "+" separator producing an empty
// trailing split part. They must still resolve to the "+" key.
it('should match a typed plus sign against the "+" keyId', () => {
assert.strictEqual(matchesKey("+", "+"), true);
});

it('should match Kitty ctrl+plus against "ctrl++"', () => {
// '+' = codepoint 43, ctrl = modifier 4 (+1 = 5)
assert.strictEqual(matchesKey("\x1b[43;5u", "ctrl++"), true);
});

it('should match Kitty ctrl+shift+plus against "ctrl+shift++"', () => {
// shift(1) + ctrl(4) = 5 (+1 = 6)
assert.strictEqual(matchesKey("\x1b[43;6u", "ctrl+shift++"), true);
});

it("should not match other input against plus keyIds", () => {
assert.strictEqual(matchesKey("-", "+"), false);
assert.strictEqual(matchesKey("+", "ctrl++"), false);
assert.strictEqual(matchesKey("\x1b[43;3u", "ctrl++"), false); // alt, not ctrl
});

it('should keep matching "ctrl+-" (hyphen key) correctly', () => {
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
});
});
});
51 changes: 51 additions & 0 deletions packages/tui/test/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,4 +1190,55 @@ bar`,
);
});
});

describe("Nested lists with color codes disabled (NO_COLOR)", () => {
// Nested-list detection must not depend on the bullet's ANSI color:
// with colors disabled (NO_COLOR, piped output) or a non-cyan theme,
// nested list lines must still keep their own indentation and must not
// receive an extra parent bullet or continuation indent.
const identity = (text: string) => text;
const plainTheme = {
heading: identity,
link: identity,
linkUrl: identity,
code: identity,
codeBlock: identity,
codeBlockBorder: identity,
quote: identity,
quoteBorder: identity,
hr: identity,
listBullet: identity,
bold: identity,
italic: identity,
strikethrough: identity,
underline: identity,
};

it("should indent a nested list by exactly one level", () => {
const markdown = new Markdown("- a\n - b", 0, 0, plainTheme);
const lines = markdown.render(80).map((line) => line.trimEnd());

assert.deepStrictEqual(lines, ["- a", " - b"]);
});

it("should not add a parent bullet when the item starts with a nested list", () => {
const markdown = new Markdown("- - b", 0, 0, plainTheme);
const lines = markdown.render(80).map((line) => line.trimEnd());

assert.deepStrictEqual(lines, [" - b"]);
});

it("should keep continuation content indented under its item", () => {
const markdown = new Markdown(
"- a\n\n second paragraph",
0,
0,
plainTheme,
);
const lines = markdown.render(80).map((line) => line.trimEnd());

assert.ok(lines.includes("- a"));
assert.ok(lines.includes(" second paragraph"));
});
});
});
62 changes: 62 additions & 0 deletions packages/tui/test/visible-width.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Tests for visibleWidth() grapheme width calculation.
*/

import assert from "node:assert";
import { describe, it } from "vitest";
import { truncateToWidth, visibleWidth } from "../src/utils.js";

describe("visibleWidth", () => {
describe("combining marks (NFD text)", () => {
it("should give decomposed (NFD) and precomposed (NFC) characters the same width", () => {
// Vietnamese "ế": NFC = U+1EBF (1 code point), NFD = e + U+0302 + U+0301
const nfc = "\u1ebf";
const nfd = "e\u0302\u0301";
assert.strictEqual(nfc.normalize("NFD"), nfd);
assert.strictEqual(visibleWidth(nfc), 1);
assert.strictEqual(visibleWidth(nfd), 1);
});

it("should count a base letter with two combining marks as width 1", () => {
// a + combining acute (U+0301) + combining dot below (U+0323)
assert.strictEqual(visibleWidth("a\u0301\u0323"), 1);
});

it("should measure NFD strings the same as their NFC form", () => {
const nfc = "Vi\u1ec7t Nam \u1ebf\u1ec7"; // precomposed
const nfd = nfc.normalize("NFD");
assert.strictEqual(visibleWidth(nfd), visibleWidth(nfc));
});

it("should pad truncated NFD text to exactly maxWidth", () => {
const nfd = "tri\u1ebfn khai".normalize("NFD");
const padded = truncateToWidth(nfd, 6, "...", true);
assert.strictEqual(visibleWidth(padded), 6);
});
});

describe("emoji widths stay correct", () => {
it("should keep single-codepoint emoji at width 2", () => {
assert.strictEqual(visibleWidth("\u{1F44D}"), 2); // thumbs up
});

it("should keep ZWJ sequences at width 2", () => {
assert.strictEqual(
visibleWidth("\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"), // family
2,
);
});

it("should keep skin-tone modified emoji at width 2", () => {
assert.strictEqual(visibleWidth("\u{1F44D}\u{1F3FD}"), 2);
});

it("should keep regional-indicator flags at width 2", () => {
assert.strictEqual(visibleWidth("\u{1F1FA}\u{1F1F8}"), 2); // US flag
});

it("should keep keycap sequences (VS16) at width 2", () => {
assert.strictEqual(visibleWidth("1\uFE0F\u20E3"), 2);
});
});
});
Loading