Skip to content

Commit b408789

Browse files
committed
feat(cli): wrap CJK text by character while preserving English words and cursor alignment
1 parent 13d7e79 commit b408789

10 files changed

Lines changed: 211 additions & 16 deletions

File tree

‎package-lock.json‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cli/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
},
3535
"dependencies": {
3636
"@vegamo/deepcode-core": "file:../core",
37+
"ansi-regex": "^6.2.2",
3738
"chalk": "^5.6.2",
3839
"gradient-string": "^3.0.0",
3940
"ignore": "^7.0.5",

‎packages/cli/src/tests/message-view.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,21 @@ test("MessageView keeps prompt words together in user and model-change echoes",
236236
}
237237
});
238238

239+
test("MessageView fills remaining columns with Chinese while preserving English words", () => {
240+
for (const role of ["user", "system"] as const) {
241+
for (const [content, expected] of [
242+
["aaaaaa 君不见高堂明镜悲白发", " > aaaaaa 君\n 不见高堂明\n 镜悲白发\n"],
243+
["中文hello世界", " > 中文hello\n 世界\n"],
244+
["中文 hello world", " > 中文 hello\n world\n"],
245+
["君不见黄河之水天上来\n君不见高堂明镜悲白发", " > 君不见黄河\n 之水天上来\n 君不见高堂\n 明镜悲白发\n"],
246+
]) {
247+
const message = makeSessionMessage({ role, content, meta: { isModelChange: true } });
248+
const output = renderToString(React.createElement(MessageView, { message, width: 13 }), { columns: 13 });
249+
assert.equal(stripAnsi(output), expected);
250+
}
251+
}
252+
});
253+
239254
test("MessageView echoes model changes with submitted prompt wrapping", () => {
240255
const msg = makeSessionMessage({
241256
role: "system",

‎packages/cli/src/tests/prompt-input-keys.test.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import React from "react";
4-
import { Box, Text, renderToString } from "ink";
4+
import { Box, renderToString } from "ink";
55
import stringWidth from "string-width";
6+
import { PromptText } from "../ui/components/PromptText";
67

78
const ANSI_RE = /\u001b\[[0-9;]*m/g;
89
function stripAnsi(text: string): string {
@@ -507,7 +508,7 @@ test("prompt cursor follows the whole word after its source offset", () => {
507508
}
508509
});
509510

510-
test("prompt cursor cells agree with Ink word wrapping and inline ANSI styling", () => {
511+
test("prompt cursor cells agree with prompt wrapping and inline ANSI styling", () => {
511512
for (const [text, width] of [
512513
["", 10],
513514
["hello world", 10],
@@ -517,6 +518,11 @@ test("prompt cursor cells agree with Ink word wrapping and inline ANSI styling",
517518
["hello world ", 10],
518519
["你好世界", 5],
519520
["hi 你好 world", 10],
521+
["aaaaaa 君不见高堂明镜悲白发", 10],
522+
["中文hello世界", 10],
523+
["请使用 hello world 测试中文换行", 12],
524+
["hello你好\n世界 cafe\u0301 👩‍💻", 10],
525+
["你好 [paste #1 1234 chars] 世界", 10],
520526
["cafe\u0301 😀 👩‍💻 world", 10],
521527
["abc", 0],
522528
["hello [paste #1 1234 chars]", 10],
@@ -530,8 +536,8 @@ test("prompt cursor cells agree with Ink word wrapping and inline ANSI styling",
530536
Box,
531537
{ width: columns },
532538
React.createElement(
533-
Text,
534-
{ wrap: "wrap" },
539+
PromptText,
540+
{ width: columns },
535541
renderBufferWithCursor(state, true, undefined, new Map([[1, "paste"]]))
536542
)
537543
),

‎packages/cli/src/tests/prompt-input-wrap.test.ts‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import React from "react";
44
import { PromptInput, type PromptSubmission } from "../ui/views/PromptInput";
55
import { terminal } from "./terminal-helper";
66

7-
test("PromptInput wraps words, switches cursor modes and submits the original buffer", async (t) => {
8-
const submissions: PromptSubmission[] = [];
9-
const props = {
7+
function inputProps(submissions: PromptSubmission[]) {
8+
return {
109
projectRoot: process.cwd(),
1110
screenWidth: 12,
1211
skills: [],
@@ -23,6 +22,11 @@ test("PromptInput wraps words, switches cursor modes and submits the original bu
2322
onPlanModeChange() {},
2423
onInterrupt() {},
2524
};
25+
}
26+
27+
test("PromptInput wraps words, switches cursor modes and submits the original buffer", async (t) => {
28+
const submissions: PromptSubmission[] = [];
29+
const props = inputProps(submissions);
2630
const h = await terminal(t, React.createElement(PromptInput, props), 24);
2731
await h.send("hello");
2832
assert.match(h.frame(), /> hello/);
@@ -50,3 +54,54 @@ test("PromptInput wraps words, switches cursor modes and submits the original bu
5054
await h.send("\r");
5155
assert.equal(submissions[0]?.text, "hello world");
5256
});
57+
58+
test("PromptInput wraps Chinese by character and edits across the wrap without changing submitted whitespace", async (t) => {
59+
const submissions: PromptSubmission[] = [];
60+
const h = await terminal(t, React.createElement(PromptInput, inputProps(submissions)), 12);
61+
const text = "aaaaaa 君不见高堂明镜悲白发\nhello world";
62+
await h.send(`\x1b[200~${text}\x1b[201~`);
63+
assert.match(h.frame(), /> aaaaaa 君\s*\n {2}不见高堂明\s*\n {2}镜悲白发/);
64+
65+
await h.send("\x1b[A");
66+
await h.send("\x01");
67+
for (let i = 0; i < 8; i++) await h.send("\x1b[C");
68+
assert.ok(h.rawFrame().includes("\x1b[7m不\x1b[27m"));
69+
await h.send("天");
70+
assert.match(h.frame(), /> aaaaaa 君\s*\n {2}天不见高堂/);
71+
await h.send("\x7f");
72+
await h.send("\x1b[3~");
73+
await h.send("不");
74+
await h.send("\r");
75+
assert.equal(submissions[0]?.text, text);
76+
});
77+
78+
test("PromptInput reflows mixed Chinese and English when resized and preserves the cursor", async (t) => {
79+
const submissions: PromptSubmission[] = [];
80+
const props = inputProps(submissions);
81+
const h = await terminal(t, React.createElement(PromptInput, props), 24);
82+
await h.send("aaaaaa 君不见");
83+
await h.send("\x1b[D");
84+
assert.ok(h.rawFrame().includes("\x1b[7m见\x1b[27m"));
85+
86+
h.rerender(React.createElement(PromptInput, { ...props, screenWidth: 24 }));
87+
await h.send("天");
88+
assert.match(h.frame(), /> aaaaaa 君不天见/);
89+
h.rerender(React.createElement(PromptInput, props));
90+
await h.send("\x7f");
91+
assert.match(h.frame(), /> aaaaaa 君\s*\n {2}不见/);
92+
await h.send("\r");
93+
assert.equal(submissions[0]?.text, "aaaaaa 君不见");
94+
});
95+
96+
test("PromptInput uses the same Chinese wrapping in highlighted history previews", async (t) => {
97+
const submissions: PromptSubmission[] = [];
98+
const text = "aaaaaa 君不见高堂明镜悲白发";
99+
const props = { ...inputProps(submissions), promptHistory: [text] };
100+
const h = await terminal(t, React.createElement(PromptInput, props), 12);
101+
await h.send("\x12君不见");
102+
assert.match(h.frame(), /> aaaaaa 君\s*\n {2}不见高堂明/);
103+
await h.send("\r");
104+
assert.equal(submissions.length, 0);
105+
await h.send("\r");
106+
assert.equal(submissions[0]?.text, text);
107+
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getDiffDetails, getDiffFilePath } from "@vegamo/deepcode-core";
22
import { formatDisplayPath } from "../../core/display-path";
33
import React from "react";
44
import { Box, Text } from "ink";
5+
import { PromptText } from "../PromptText";
56
import { renderMarkdown, renderMarkdownSegments } from "./markdown";
67
import {
78
buildThinkingSummary,
@@ -172,9 +173,9 @@ function PromptEchoLine({
172173
<Text color="#229ac3">{"> "}</Text>
173174
</Box>
174175
<Box flexGrow={1} flexShrink={1} width={contentWidth}>
175-
<Text color="#229ac3" wrap="wrap">
176+
<PromptText color="#229ac3" width={contentWidth}>
176177
{text}
177-
</Text>
178+
</PromptText>
178179
{attachmentCount > 0 ? <Text color="#229ac3">{` 📎 ${attachmentCount} image attachment(s)`}</Text> : null}
179180
</Box>
180181
</Box>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from "react";
2+
import { Text, Transform } from "ink";
3+
import { wrapPromptText } from "../../core/prompt-wrap";
4+
5+
/** Apply the same wrapping to styled prompt text and its cursor measurement. */
6+
export function PromptText({
7+
width,
8+
children,
9+
...props
10+
}: React.ComponentProps<typeof Text> & { width: number }): React.ReactElement {
11+
return (
12+
<Text {...props} wrap="wrap">
13+
<Transform transform={(text) => wrapPromptText(text, width)}>{children}</Transform>
14+
</Text>
15+
);
16+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import ansiRegex from "ansi-regex";
2+
import stringWidth from "string-width";
3+
import wrapAnsi from "wrap-ansi";
4+
5+
const graphemes = new Intl.Segmenter();
6+
const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
7+
8+
type Cell = { text: string; width: number; separate: boolean; space: boolean };
9+
10+
/** Preserve Latin words while allowing a line break between CJK graphemes. */
11+
export function wrapPromptText(text: string, columns: number): string {
12+
const width = Math.max(1, columns);
13+
if (!cjk.test(text)) return wrapAnsi(text, width, { trim: false, hard: true });
14+
15+
const wrapped = text
16+
.normalize()
17+
.replaceAll("\r\n", "\n")
18+
.split("\n")
19+
.map((line) => wrapCjkLine(line, width))
20+
.join("\n");
21+
22+
// Close and reopen ANSI styles at inserted line breaks, without regrouping
23+
// the CJK characters into words. This also keeps inline cursor styling local.
24+
return wrapAnsi(wrapped, width, { trim: false, hard: true, wordWrap: false });
25+
}
26+
27+
function wrapCjkLine(line: string, width: number): string {
28+
const cells: Cell[] = [];
29+
let pendingAnsi = "";
30+
let logicalColumn = 0;
31+
const appendText = (text: string) => {
32+
for (const { segment } of graphemes.segment(text)) {
33+
const value = segment === "\t" ? " ".repeat(8 - (logicalColumn % 8)) : segment;
34+
const parts = segment === "\t" ? Array.from(value) : [value];
35+
for (const part of parts) {
36+
const cellWidth = stringWidth(part);
37+
cells.push({
38+
text: pendingAnsi + part,
39+
width: cellWidth,
40+
separate: part === " " || cjk.test(part),
41+
space: part === " ",
42+
});
43+
pendingAnsi = "";
44+
logicalColumn += cellWidth;
45+
}
46+
}
47+
};
48+
49+
let offset = 0;
50+
for (const match of line.matchAll(ansiRegex())) {
51+
appendText(line.slice(offset, match.index));
52+
pendingAnsi += match[0];
53+
offset = match.index + match[0].length;
54+
}
55+
appendText(line.slice(offset));
56+
57+
const output: string[] = [];
58+
let column = 0;
59+
const newline = () => {
60+
output.push("\n");
61+
column = 0;
62+
};
63+
const appendWord = (word: Cell[]) => {
64+
const wordWidth = word.reduce((total, cell) => total + cell.width, 0);
65+
if (wordWidth <= width) {
66+
if (column > 0 && column + wordWidth > width) newline();
67+
} else {
68+
// Match wrap-ansi's handling of oversized Latin words: move the word
69+
// first only when doing so avoids an extra line break.
70+
const breaksHere = 1 + Math.floor((wordWidth - (width - column) - 1) / width);
71+
const breaksNext = Math.floor((wordWidth - 1) / width);
72+
if (column > 0 && breaksNext < breaksHere) newline();
73+
}
74+
for (const cell of word) {
75+
if (column > 0 && column + cell.width > width) newline();
76+
output.push(cell.text);
77+
column += cell.width;
78+
}
79+
};
80+
81+
let word: Cell[] = [];
82+
for (const cell of cells) {
83+
if (!cell.separate) {
84+
word.push(cell);
85+
continue;
86+
}
87+
appendWord(word);
88+
word = [];
89+
if (cell.space) {
90+
if (column >= width) newline();
91+
output.push(cell.text);
92+
column += cell.width;
93+
} else {
94+
appendWord([cell]);
95+
}
96+
}
97+
appendWord(word);
98+
output.push(pendingAnsi);
99+
return output.join("");
100+
}

‎packages/cli/src/ui/hooks/cursor.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useCursor, useBoxMetrics } from "ink";
22
import stringWidth from "string-width";
3-
import wrapAnsi from "wrap-ansi";
43
import { useLayoutEffect, useState } from "react";
54
import type { RefObject } from "react";
65
import type { DOMElement } from "ink";
76
import type { PromptBufferState } from "../core/prompt-buffer";
7+
import { wrapPromptText } from "../core/prompt-wrap";
88

99
export type CursorPlacement = {
1010
row: number;
@@ -86,10 +86,9 @@ function measurePromptCursor(state: PromptBufferState, screenWidth: number, init
8686
}
8787
}
8888

89-
// Match Ink's Text wrap="wrap", including preserved spaces and hard
90-
// breaks for oversized words. The suffix matters even before the cursor:
89+
// Use the same CJK-aware wrapping as PromptText. The suffix matters even before the cursor:
9190
// it can move the entire word containing that cursor onto the next row.
92-
const rows = wrapAnsi(normalized, contentWidth, { trim: false, hard: true }).split("\n");
91+
const rows = wrapPromptText(normalized, contentWidth).split("\n");
9392
if (cursor > sourceStart + line.length) {
9493
row += rows.length;
9594
sourceStart += line.length + 1;

‎packages/cli/src/ui/views/PromptInput.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Box, Text, useStdout } from "ink";
55
import type { DOMElement } from "ink";
66
import chalk from "chalk";
77
import { ARGS_SEPARATOR } from "../constants";
8+
import { PromptText } from "../components/PromptText";
89
import {
910
EMPTY_BUFFER,
1011
PASTE_MARKER_REGEX,
@@ -974,7 +975,7 @@ export const PromptInput = React.memo(function PromptInput({
974975
>
975976
<PromptPrefixLine />
976977
<Box ref={inputTextRef} flexGrow={1} flexShrink={1} width={inputContentWidth}>
977-
<Text wrap="wrap">
978+
<PromptText width={inputContentWidth}>
978979
{search?.preview !== null && search?.preview !== undefined ? (
979980
<HistoryPreview text={search.preview} query={search.query} />
980981
) : (
@@ -986,7 +987,7 @@ export const PromptInput = React.memo(function PromptInput({
986987
!busy && !terminalCursorActive && !search
987988
)
988989
)}
989-
</Text>
990+
</PromptText>
990991
{inlineHint ? <Text dimColor>{inlineHint}</Text> : null}
991992
</Box>
992993
</Box>

0 commit comments

Comments
 (0)