Skip to content

Commit a1c9005

Browse files
fix(cli): align markdown tables with emoji and prevent terminal wrap
visualWidth miscounted flag emoji (🇧🇷 = 4 cells instead of 2), keycap sequences (0️⃣ counted variation selector and combining keycap as 1 each), and ZWJ/tag modifiers — leaving cells too narrow and pipes misaligned. Also reserve one terminal column for the cursor so full-width tables don't soft-wrap at the right edge.
1 parent 80a093b commit a1c9005

4 files changed

Lines changed: 215 additions & 4 deletions

File tree

packages/cli/src/tests/markdown.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,186 @@ test("renderMarkdown preserves empty table cells", () => {
9595
assert.equal((bodyRow.match(//g) ?? []).length, 4);
9696
});
9797

98+
test("renderMarkdown aligns table borders when rows contain flag emoji", () => {
99+
// Flag emoji (🇧🇷) are regional-indicator pairs — each code is wide by
100+
// EastAsianWidth, but together they render as one 2-cell flag. visualWidth
101+
// must treat the pair as 2 cells, otherwise data rows misalign with borders.
102+
const table = [
103+
"| 对阵 | 比分 | 状态 |",
104+
"|---|---|---|",
105+
"| 🇧🇷 巴西 vs 🇳🇴 挪威 | 1 - 2 | 已结束 |",
106+
"| 🇲🇽 墨西哥 vs 🏴󠁧󠁢󠁥󠁮󠁧󠁿 英格兰 | 1 - 2 | 进行中 |",
107+
].join("\n");
108+
109+
const segment = renderMarkdownSegments(table, 120).find((item) => item.kind === "table");
110+
assert.ok(segment);
111+
const stripped = stripAnsi(segment.body);
112+
const lines = stripped.split("\n");
113+
const border = lines.find((l) => l.startsWith("├"));
114+
assert.ok(border);
115+
116+
// Border lines contain only single-cell box characters, so character index
117+
// equals visual column there. Collect every pipe position on the border.
118+
const expectedCols: number[] = [];
119+
for (let i = 0; i < border.length; i++) {
120+
const c = border[i];
121+
if (c === "├" || c === "┼" || c === "┤") expectedCols.push(i);
122+
}
123+
assert.ok(expectedCols.length >= 3);
124+
125+
// On data rows the character index of '│' is NOT the visual column (emoji
126+
// and CJK shift things), so walk the string tracking visual width to find
127+
// the visual column where each '│' actually lands.
128+
const visualColsOfPipes = (line: string): number[] => {
129+
const cols: number[] = [];
130+
let col = 0;
131+
const chars = Array.from(line);
132+
for (let i = 0; i < chars.length; i++) {
133+
const ch = chars[i];
134+
if (ch === "│") {
135+
cols.push(col);
136+
col += 1;
137+
continue;
138+
}
139+
const code = ch.codePointAt(0) ?? 0;
140+
// Zero-width sequences: ZWJ, emoji tags, variation selectors, combining
141+
// marks, and the keycap combining enclosure (U+20E3).
142+
if (
143+
code === 0x200d ||
144+
(code >= 0xe0020 && code <= 0xe007f) ||
145+
(code >= 0xfe00 && code <= 0xfe0f) ||
146+
(code >= 0x0300 && code <= 0x036f) ||
147+
code === 0x20e3
148+
)
149+
continue;
150+
// Regional indicators form a flag pair — count 2 cells for the pair.
151+
if (code >= 0x1f1e6 && code <= 0x1f1ff) {
152+
const next = chars[i + 1]?.codePointAt(0) ?? 0;
153+
if (next >= 0x1f1e6 && next <= 0x1f1ff) {
154+
col += 2;
155+
i++;
156+
continue;
157+
}
158+
}
159+
// Astral-plane chars (surrogate pairs) are wide.
160+
if (ch.length >= 2) {
161+
col += 2;
162+
continue;
163+
}
164+
// CJK / fullwidth / pictographs are wide.
165+
const isWide =
166+
(code >= 0x1100 && code <= 0x115f) ||
167+
(code >= 0x2e80 && code <= 0xa4cf) ||
168+
(code >= 0xac00 && code <= 0xd7af) ||
169+
(code >= 0xf900 && code <= 0xfaff) ||
170+
(code >= 0xfe10 && code <= 0xfe6f) ||
171+
(code >= 0xff00 && code <= 0xffe6) ||
172+
(code >= 0x20000 && code <= 0x3fffd) ||
173+
(code >= 0x1f300 && code <= 0x1faff) ||
174+
(code >= 0x2600 && code <= 0x27bf) ||
175+
(code >= 0x2300 && code <= 0x23ff) ||
176+
(code >= 0x2b00 && code <= 0x2bff) ||
177+
(code >= 0x1f000 && code <= 0x1f02f);
178+
col += isWide ? 2 : 1;
179+
}
180+
return cols;
181+
};
182+
183+
for (const line of lines) {
184+
if (!line.includes("│") || line.startsWith("┌") || line.startsWith("├") || line.startsWith("└")) continue;
185+
assert.deepEqual(
186+
visualColsOfPipes(line),
187+
expectedCols,
188+
`data row pipes must line up with border crossings: ${line}`
189+
);
190+
}
191+
});
192+
193+
test("renderMarkdown aligns table borders when rows contain keycap emoji", () => {
194+
// Keycap emoji like 0️⃣ are a base digit + U+FE0F variation selector + U+20E3
195+
// combining enclosing keycap. Both modifiers are zero-width — if visualWidth
196+
// counts them as 1 each, the cell ends up 2 cells too wide and pipes misalign.
197+
const table = [
198+
"| 日期 | 对阵 | 比分 | 晋级方 | 备注 |",
199+
"|---|---|---|---|---|",
200+
"| 7月4日 | 加拿大 vs 摩洛哥 | 0️⃣ - 3 🏆 | 摩洛哥 | 首场1/8决赛 |",
201+
"| 7月5日 | 巴西 vs 挪威 | 1️⃣ - 2 🏆 | 挪威 ⚡ | 大冷门! |",
202+
"| 7月6日 🟡 | 葡萄牙 vs 西班牙 | 今晚 7:00PM | ⏳ 待赛 | 伊比利亚德比🔥 |",
203+
].join("\n");
204+
205+
const segment = renderMarkdownSegments(table, 120).find((item) => item.kind === "table");
206+
assert.ok(segment);
207+
const stripped = stripAnsi(segment.body);
208+
const lines = stripped.split("\n");
209+
const border = lines.find((l) => l.startsWith("├"));
210+
assert.ok(border);
211+
212+
const expectedCols: number[] = [];
213+
for (let i = 0; i < border.length; i++) {
214+
const c = border[i];
215+
if (c === "├" || c === "┼" || c === "┤") expectedCols.push(i);
216+
}
217+
218+
const visualColsOfPipes = (line: string): number[] => {
219+
const cols: number[] = [];
220+
let col = 0;
221+
const chars = Array.from(line);
222+
for (let i = 0; i < chars.length; i++) {
223+
const ch = chars[i];
224+
if (ch === "│") {
225+
cols.push(col);
226+
col += 1;
227+
continue;
228+
}
229+
const code = ch.codePointAt(0) ?? 0;
230+
if (
231+
code === 0x200d ||
232+
(code >= 0xe0020 && code <= 0xe007f) ||
233+
(code >= 0xfe00 && code <= 0xfe0f) ||
234+
(code >= 0x0300 && code <= 0x036f) ||
235+
code === 0x20e3
236+
)
237+
continue;
238+
if (code >= 0x1f1e6 && code <= 0x1f1ff) {
239+
const next = chars[i + 1]?.codePointAt(0) ?? 0;
240+
if (next >= 0x1f1e6 && next <= 0x1f1ff) {
241+
col += 2;
242+
i++;
243+
continue;
244+
}
245+
}
246+
if (ch.length >= 2) {
247+
col += 2;
248+
continue;
249+
}
250+
const isWide =
251+
(code >= 0x1100 && code <= 0x115f) ||
252+
(code >= 0x2e80 && code <= 0xa4cf) ||
253+
(code >= 0xac00 && code <= 0xd7af) ||
254+
(code >= 0xf900 && code <= 0xfaff) ||
255+
(code >= 0xfe10 && code <= 0xfe6f) ||
256+
(code >= 0xff00 && code <= 0xffe6) ||
257+
(code >= 0x20000 && code <= 0x3fffd) ||
258+
(code >= 0x1f300 && code <= 0x1faff) ||
259+
(code >= 0x2600 && code <= 0x27bf) ||
260+
(code >= 0x2300 && code <= 0x23ff) ||
261+
(code >= 0x2b00 && code <= 0x2bff) ||
262+
(code >= 0x1f000 && code <= 0x1f02f);
263+
col += isWide ? 2 : 1;
264+
}
265+
return cols;
266+
};
267+
268+
for (const line of lines) {
269+
if (!line.includes("│") || line.startsWith("┌") || line.startsWith("├") || line.startsWith("└")) continue;
270+
assert.deepEqual(
271+
visualColsOfPipes(line),
272+
expectedCols,
273+
`data row pipes must line up with border crossings: ${line}`
274+
);
275+
}
276+
});
277+
98278
test("renderMarkdown keeps text separated from rendered table blocks", () => {
99279
const result = stripAnsi(renderMarkdown("Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter", 40));
100280
assert.equal(result.includes("Before\n┌"), true);

packages/cli/src/ui/components/MessageView/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
6868
? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => {
6969
if (seg.kind === "table") {
7070
return (
71-
<Box key={i} flexDirection="column">
71+
<Box key={i} flexDirection="column" width={contentWidth}>
7272
{seg.body.split("\n").map((line, lineIndex) => (
7373
<Text key={lineIndex} wrap="truncate-end">
7474
{line}

packages/cli/src/ui/components/MessageView/markdown.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,39 @@ function flushTextOnly(buffer: string[], tableRows: string[][]): TableBlock[] {
192192

193193
function visualWidth(text: string): number {
194194
let w = 0;
195-
for (const ch of text) {
195+
const chars = Array.from(text);
196+
for (let i = 0; i < chars.length; i++) {
197+
const ch = chars[i];
198+
const code = ch.codePointAt(0) ?? ch.charCodeAt(0);
199+
200+
// Zero-width sequences: ZWJ, emoji tag modifiers, variation selectors
201+
// (U+FE00–FE0F), and combining marks (U+0300–U+036F, U+20E3 keycap cap)
202+
// take no terminal column — they decorate the preceding character.
203+
if (
204+
code === 0x200d ||
205+
(code >= 0xe0020 && code <= 0xe007f) ||
206+
(code >= 0xfe00 && code <= 0xfe0f) ||
207+
(code >= 0x0300 && code <= 0x036f) ||
208+
code === 0x20e3
209+
)
210+
continue;
211+
212+
// Regional indicators come in pairs to form a flag (🇧🇷). Each lone code is
213+
// wide by EastAsianWidth, but together they render as one 2-cell flag, so
214+
// consume the pair and count 2 instead of 4.
215+
if (code >= 0x1f1e6 && code <= 0x1f1ff) {
216+
const next = chars[i + 1]?.codePointAt(0) ?? 0;
217+
if (next >= 0x1f1e6 && next <= 0x1f1ff) {
218+
w += 2;
219+
i++;
220+
continue;
221+
}
222+
}
223+
196224
if (ch.length >= 2) {
197225
w += 2;
198226
continue;
199227
}
200-
const code = ch.codePointAt(0) ?? ch.charCodeAt(0);
201228
w += isWideChar(code) ? 2 : 1;
202229
}
203230
return w;

packages/cli/src/ui/views/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
691691
}, 0);
692692
}, [busy, mode, sessionManager, columns, stdout]);
693693

694-
const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]);
694+
// Many terminals wrap a line that writes into the final column (the cursor
695+
// column). Reserve one column so table borders and other full-width content
696+
// don't get split across rows. Without this, a table rendered to exactly
697+
// `columns` cells will soft-wrap on most terminal emulators.
698+
const screenWidth = useMemo(() => Math.max(20, (columns ?? stdout?.columns ?? 80) - 1), [columns, stdout]);
695699
const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]);
696700
const getSessionInfo = useCallback((): SessionInfo | null => {
697701
const activeSessionId = sessionManager.getActiveSessionId();

0 commit comments

Comments
 (0)