Skip to content

Commit 13d7e79

Browse files
authored
Merge pull request #401 from mvanhorn/fix/327-prompt-word-wrapping
fix(ui): wrap prompt words without misplacing the cursor
2 parents a71fbb4 + a0ae522 commit 13d7e79

6 files changed

Lines changed: 184 additions & 77 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ test("MessageView echoes submitted user prompts with live prompt wrapping width"
228228
);
229229
});
230230

231+
test("MessageView keeps prompt words together in user and model-change echoes", () => {
232+
for (const role of ["user", "system"] as const) {
233+
const message = makeSessionMessage({ role, content: "hello world", meta: { isModelChange: true } });
234+
const output = renderToString(React.createElement(MessageView, { message, width: 13 }), { columns: 13 });
235+
assert.equal(stripAnsi(output), " > hello\n world\n");
236+
}
237+
});
238+
231239
test("MessageView echoes model changes with submitted prompt wrapping", () => {
232240
const msg = makeSessionMessage({
233241
role: "system",

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3+
import React from "react";
4+
import { Box, Text, renderToString } from "ink";
5+
import stringWidth from "string-width";
36

47
const ANSI_RE = /\u001b\[[0-9;]*m/g;
58
function stripAnsi(text: string): string {
@@ -490,3 +493,65 @@ test("dispatchTerminalInput preserves text coalesced after history search shortc
490493
]
491494
);
492495
});
496+
497+
test("prompt cursor follows the whole word after its source offset", () => {
498+
for (const [cursor, row, column] of [
499+
[0, 0, 0],
500+
[5, 0, 5],
501+
[6, 1, 0],
502+
[8, 1, 2],
503+
[11, 1, 5],
504+
]) {
505+
assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor }, 10), { row, column });
506+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello world", cursor }, 10), row > 0);
507+
}
508+
});
509+
510+
test("prompt cursor cells agree with Ink word wrapping and inline ANSI styling", () => {
511+
for (const [text, width] of [
512+
["", 10],
513+
["hello world", 10],
514+
["hi abcdefghijkl", 5],
515+
["hello world", 5],
516+
["hello\nworld\n", 5],
517+
["hello world ", 10],
518+
["你好世界", 5],
519+
["hi 你好 world", 10],
520+
["cafe\u0301 😀 👩‍💻 world", 10],
521+
["abc", 0],
522+
["hello [paste #1 1234 chars]", 10],
523+
["Please wrap this sentence before the following long word: " + "x".repeat(80), 118],
524+
] as const) {
525+
const columns = Math.max(1, width);
526+
for (let cursor = 0; cursor <= text.length; cursor++) {
527+
const state = { text, cursor };
528+
const rendered = renderToString(
529+
React.createElement(
530+
Box,
531+
{ width: columns },
532+
React.createElement(
533+
Text,
534+
{ wrap: "wrap" },
535+
renderBufferWithCursor(state, true, undefined, new Map([[1, "paste"]]))
536+
)
537+
),
538+
{ columns }
539+
);
540+
const marker = rendered.indexOf("\u001B[7m");
541+
assert.ok(marker >= 0, JSON.stringify({ text, cursor, rendered }));
542+
const before = stripAnsi(rendered.slice(0, marker)).split("\n");
543+
assert.deepEqual(
544+
getPromptCursorPlacement(state, width),
545+
{
546+
row: before.length - 1,
547+
column: stringWidth(before.at(-1)!),
548+
},
549+
JSON.stringify({ text, cursor, rendered })
550+
);
551+
assert.equal((rendered.match(/\u001B\[7m/g) ?? []).length, 1, JSON.stringify({ text, cursor, rendered }));
552+
}
553+
}
554+
assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: -1 }, 10), { row: 0, column: 0 });
555+
assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: 99 }, 10), { row: 1, column: 5 });
556+
assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: 8 }, 12, 2), { row: 1, column: 4 });
557+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import React from "react";
4+
import { PromptInput, type PromptSubmission } from "../ui/views/PromptInput";
5+
import { terminal } from "./terminal-helper";
6+
7+
test("PromptInput wraps words, switches cursor modes and submits the original buffer", async (t) => {
8+
const submissions: PromptSubmission[] = [];
9+
const props = {
10+
projectRoot: process.cwd(),
11+
screenWidth: 12,
12+
skills: [],
13+
reasoningSummary: "off" as const,
14+
onReasoningSummaryChange: () => "",
15+
modelConfig: { model: "test", thinkingEnabled: false, reasoningEffort: "high" as const },
16+
promptHistory: [],
17+
busy: false,
18+
planMode: false,
19+
onSubmit: (submission: PromptSubmission) => void submissions.push(submission),
20+
onPromptDraftApplied() {},
21+
onModelConfigChange: () => "",
22+
onPermissionSettingsChange: () => "",
23+
onPlanModeChange() {},
24+
onInterrupt() {},
25+
};
26+
const h = await terminal(t, React.createElement(PromptInput, props), 24);
27+
await h.send("hello");
28+
assert.match(h.frame(), /> hello/);
29+
assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
30+
31+
await h.send(" world");
32+
assert.match(h.frame(), /> hello\s*\n {2}world/);
33+
assert.equal((h.rawFrame().match(/\u001B\[7m/g) ?? []).length, 1);
34+
for (const letter of ["d", "l", "r", "o", "w"]) {
35+
await h.send("\x1b[D");
36+
assert.match(h.frame(), /> hello\s*\n {2}world/);
37+
assert.ok(h.rawFrame().includes(`\x1b[7m${letter}\x1b[27m`));
38+
}
39+
await h.send("\x1b[D");
40+
assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
41+
await h.send("\x1b[C");
42+
assert.ok(h.rawFrame().includes("\x1b[7mw\x1b[27m"));
43+
await h.send("\x1b[C");
44+
assert.ok(h.rawFrame().includes("\x1b[7mo\x1b[27m"));
45+
46+
h.rerender(React.createElement(PromptInput, { ...props, screenWidth: 24 }));
47+
await h.send("\x1b[C");
48+
assert.match(h.frame(), /> hello world/);
49+
assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
50+
await h.send("\r");
51+
assert.equal(submissions[0]?.text, "hello world");
52+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ function PromptEchoLine({
172172
<Text color="#229ac3">{"> "}</Text>
173173
</Box>
174174
<Box flexGrow={1} flexShrink={1} width={contentWidth}>
175-
<Text color="#229ac3" wrap="hard">
175+
<Text color="#229ac3" wrap="wrap">
176176
{text}
177177
</Text>
178178
{attachmentCount > 0 ? <Text color="#229ac3">{` 📎 ${attachmentCount} image attachment(s)`}</Text> : null}

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

Lines changed: 48 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { useCursor, useBoxMetrics } from "ink";
2+
import stringWidth from "string-width";
3+
import wrapAnsi from "wrap-ansi";
24
import { useLayoutEffect, useState } from "react";
35
import type { RefObject } from "react";
46
import type { DOMElement } from "ink";
@@ -52,88 +54,65 @@ export function getPromptCursorPlacement(
5254
screenWidth: number,
5355
initialColumn = 0
5456
): CursorPlacement {
55-
const width = Math.max(1, screenWidth);
56-
const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
57-
const beforeCursor = state.text.slice(0, cursor);
58-
const cursorPosition = measureTextPosition(beforeCursor, width, initialColumn);
59-
return { row: cursorPosition.row, column: cursorPosition.column };
57+
const { row, column } = measurePromptCursor(state, screenWidth, initialColumn);
58+
return { row, column };
6059
}
6160

6261
export function isPromptCursorAtWrapBoundary(state: PromptBufferState, screenWidth: number): boolean {
63-
const width = Math.max(1, screenWidth);
64-
const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
65-
const currentLineStart = state.text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
66-
const currentLineBeforeCursor = state.text.slice(currentLineStart, cursor);
67-
return measureTextPosition(currentLineBeforeCursor, width, 0).row > 0;
62+
return measurePromptCursor(state, screenWidth).softWrapped;
6863
}
6964

70-
function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } {
65+
const graphemes = new Intl.Segmenter();
66+
67+
function measurePromptCursor(state: PromptBufferState, screenWidth: number, initialColumn = 0) {
68+
const width = Math.max(1, screenWidth);
69+
const indent = Math.max(0, Math.min(initialColumn, width - 1));
70+
const contentWidth = width - indent;
71+
const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
72+
let sourceStart = 0;
7173
let row = 0;
72-
let column = Math.min(initialColumn, width - 1);
73-
let pendingWrap = false;
74-
75-
for (const char of Array.from(text)) {
76-
if (char === "\n") {
77-
row++;
78-
column = Math.min(initialColumn, width - 1);
79-
pendingWrap = false;
80-
continue;
81-
}
8274

83-
if (pendingWrap) {
84-
row++;
85-
column = Math.min(initialColumn, width - 1);
86-
pendingWrap = false;
75+
for (const line of state.text.split("\n")) {
76+
// wrap-ansi normalizes Unicode and expands tabs. Keep the cursor's source
77+
// offset while applying those transformations, snapping inside a grapheme
78+
// to its first cell rather than measuring partial emoji or accents.
79+
let normalized = "";
80+
let offset = 0;
81+
for (const { segment, index } of graphemes.segment(line)) {
82+
const value = segment === "\t" ? " ".repeat(8 - (stringWidth(normalized) % 8)) : segment.normalize();
83+
normalized += value;
84+
if (sourceStart + index + segment.length <= cursor) {
85+
offset = normalized.length;
86+
}
8787
}
8888

89-
const charColumns = textWidth(char);
90-
if (column + charColumns > width) {
91-
row++;
92-
column = Math.min(initialColumn, width - 1);
93-
}
94-
column += charColumns;
95-
if (column >= width) {
96-
column = width;
97-
pendingWrap = true;
89+
// Match Ink's Text wrap="wrap", including preserved spaces and hard
90+
// breaks for oversized words. The suffix matters even before the cursor:
91+
// 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");
93+
if (cursor > sourceStart + line.length) {
94+
row += rows.length;
95+
sourceStart += line.length + 1;
96+
continue;
9897
}
99-
}
100-
101-
if (pendingWrap) {
102-
return { row: row + 1, column: Math.min(initialColumn, width - 1) };
103-
}
104-
105-
return { row, column };
106-
}
10798

108-
function textWidth(value: string): number {
109-
let width = 0;
110-
for (const char of Array.from(value.normalize())) {
111-
width += characterWidth(char);
99+
let wrappedRow = 0;
100+
for (; wrappedRow < rows.length - 1; wrappedRow++) {
101+
const length = rows[wrappedRow]!.length;
102+
if (offset < length) break;
103+
offset -= length;
104+
}
105+
const column = stringWidth(rows[wrappedRow]!.slice(0, offset));
106+
// An inline cursor reserves the next row at an exact-width line end.
107+
const pendingWrap = column >= contentWidth;
108+
return {
109+
row: row + wrappedRow + (pendingWrap ? 1 : 0),
110+
column: indent + (pendingWrap ? 0 : column),
111+
softWrapped: wrappedRow > 0 || pendingWrap,
112+
};
112113
}
113-
return width;
114-
}
115114

116-
function characterWidth(char: string): number {
117-
const codePoint = char.codePointAt(0) ?? 0;
118-
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
119-
return 0;
120-
}
121-
if (codePoint >= 0x300 && codePoint <= 0x36f) {
122-
return 0;
123-
}
124-
if (
125-
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
126-
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
127-
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
128-
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
129-
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
130-
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
131-
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
132-
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
133-
) {
134-
return 2;
135-
}
136-
return 1;
115+
return { row, column: indent, softWrapped: false };
137116
}
138117

139118
export function usePromptTerminalCursor(

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ type Props = {
131131
};
132132

133133
const PROMPT_PREFIX_WIDTH = 2;
134+
const promptGraphemes = new Intl.Segmenter();
134135

135136
const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement {
136137
return (
@@ -973,7 +974,7 @@ export const PromptInput = React.memo(function PromptInput({
973974
>
974975
<PromptPrefixLine />
975976
<Box ref={inputTextRef} flexGrow={1} flexShrink={1} width={inputContentWidth}>
976-
<Text wrap="hard">
977+
<Text wrap="wrap">
977978
{search?.preview !== null && search?.preview !== undefined ? (
978979
<HistoryPreview text={search.preview} query={search.query} />
979980
) : (
@@ -1285,7 +1286,7 @@ function renderTextSegmentWithCursor(
12851286
const cursorRel = cursor - start; // relative cursor position inside this segment
12861287

12871288
// Cursor not in this segment – just return the text.
1288-
if (cursorRel < 0 || cursorRel > segText.length) {
1289+
if (cursorRel < 0 || cursorRel > segText.length || (cursorRel === segText.length && end < text.length)) {
12891290
return highlighted ? chalk.yellow(segText) : segText;
12901291
}
12911292

@@ -1295,17 +1296,19 @@ function renderTextSegmentWithCursor(
12951296
}
12961297

12971298
// Cursor is somewhere inside the segment.
1298-
const at = segText[cursorRel];
1299+
const grapheme = promptGraphemes.segment(segText).containing(cursorRel)!;
1300+
const at = grapheme.segment;
1301+
const cellStart = grapheme.index;
12991302

13001303
if (at === "\n") {
13011304
// Render newline as a space in the cursor cell, then output the actual newline.
1302-
const before = segText.slice(0, cursorRel);
1303-
const after = segText.slice(cursorRel + 1);
1305+
const before = segText.slice(0, cellStart);
1306+
const after = segText.slice(cellStart + at.length);
13041307
return before + renderCursorCell(" ") + "\n" + after;
13051308
}
13061309

1307-
const before = segText.slice(0, cursorRel);
1308-
const after = segText.slice(cursorRel + 1);
1310+
const before = segText.slice(0, cellStart);
1311+
const after = segText.slice(cellStart + at.length);
13091312
if (highlighted) {
13101313
return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after);
13111314
}

0 commit comments

Comments
 (0)