diff --git a/packages/cli/src/tests/message-view.test.ts b/packages/cli/src/tests/message-view.test.ts
index 17953b22..7f4a18b9 100644
--- a/packages/cli/src/tests/message-view.test.ts
+++ b/packages/cli/src/tests/message-view.test.ts
@@ -228,6 +228,14 @@ test("MessageView echoes submitted user prompts with live prompt wrapping width"
);
});
+test("MessageView keeps prompt words together in user and model-change echoes", () => {
+ for (const role of ["user", "system"] as const) {
+ const message = makeSessionMessage({ role, content: "hello world", meta: { isModelChange: true } });
+ const output = renderToString(React.createElement(MessageView, { message, width: 13 }), { columns: 13 });
+ assert.equal(stripAnsi(output), " > hello\n world\n");
+ }
+});
+
test("MessageView echoes model changes with submitted prompt wrapping", () => {
const msg = makeSessionMessage({
role: "system",
diff --git a/packages/cli/src/tests/prompt-input-keys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts
index e5f78f87..1ef88834 100644
--- a/packages/cli/src/tests/prompt-input-keys.test.ts
+++ b/packages/cli/src/tests/prompt-input-keys.test.ts
@@ -1,5 +1,8 @@
import { test } from "node:test";
import assert from "node:assert/strict";
+import React from "react";
+import { Box, Text, renderToString } from "ink";
+import stringWidth from "string-width";
const ANSI_RE = /\u001b\[[0-9;]*m/g;
function stripAnsi(text: string): string {
@@ -490,3 +493,65 @@ test("dispatchTerminalInput preserves text coalesced after history search shortc
]
);
});
+
+test("prompt cursor follows the whole word after its source offset", () => {
+ for (const [cursor, row, column] of [
+ [0, 0, 0],
+ [5, 0, 5],
+ [6, 1, 0],
+ [8, 1, 2],
+ [11, 1, 5],
+ ]) {
+ assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor }, 10), { row, column });
+ assert.equal(isPromptCursorAtWrapBoundary({ text: "hello world", cursor }, 10), row > 0);
+ }
+});
+
+test("prompt cursor cells agree with Ink word wrapping and inline ANSI styling", () => {
+ for (const [text, width] of [
+ ["", 10],
+ ["hello world", 10],
+ ["hi abcdefghijkl", 5],
+ ["hello world", 5],
+ ["hello\nworld\n", 5],
+ ["hello world ", 10],
+ ["你好世界", 5],
+ ["hi 你好 world", 10],
+ ["cafe\u0301 😀 👩💻 world", 10],
+ ["abc", 0],
+ ["hello [paste #1 1234 chars]", 10],
+ ["Please wrap this sentence before the following long word: " + "x".repeat(80), 118],
+ ] as const) {
+ const columns = Math.max(1, width);
+ for (let cursor = 0; cursor <= text.length; cursor++) {
+ const state = { text, cursor };
+ const rendered = renderToString(
+ React.createElement(
+ Box,
+ { width: columns },
+ React.createElement(
+ Text,
+ { wrap: "wrap" },
+ renderBufferWithCursor(state, true, undefined, new Map([[1, "paste"]]))
+ )
+ ),
+ { columns }
+ );
+ const marker = rendered.indexOf("\u001B[7m");
+ assert.ok(marker >= 0, JSON.stringify({ text, cursor, rendered }));
+ const before = stripAnsi(rendered.slice(0, marker)).split("\n");
+ assert.deepEqual(
+ getPromptCursorPlacement(state, width),
+ {
+ row: before.length - 1,
+ column: stringWidth(before.at(-1)!),
+ },
+ JSON.stringify({ text, cursor, rendered })
+ );
+ assert.equal((rendered.match(/\u001B\[7m/g) ?? []).length, 1, JSON.stringify({ text, cursor, rendered }));
+ }
+ }
+ assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: -1 }, 10), { row: 0, column: 0 });
+ assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: 99 }, 10), { row: 1, column: 5 });
+ assert.deepEqual(getPromptCursorPlacement({ text: "hello world", cursor: 8 }, 12, 2), { row: 1, column: 4 });
+});
diff --git a/packages/cli/src/tests/prompt-input-wrap.test.ts b/packages/cli/src/tests/prompt-input-wrap.test.ts
new file mode 100644
index 00000000..7ced827a
--- /dev/null
+++ b/packages/cli/src/tests/prompt-input-wrap.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import React from "react";
+import { PromptInput, type PromptSubmission } from "../ui/views/PromptInput";
+import { terminal } from "./terminal-helper";
+
+test("PromptInput wraps words, switches cursor modes and submits the original buffer", async (t) => {
+ const submissions: PromptSubmission[] = [];
+ const props = {
+ projectRoot: process.cwd(),
+ screenWidth: 12,
+ skills: [],
+ reasoningSummary: "off" as const,
+ onReasoningSummaryChange: () => "",
+ modelConfig: { model: "test", thinkingEnabled: false, reasoningEffort: "high" as const },
+ promptHistory: [],
+ busy: false,
+ planMode: false,
+ onSubmit: (submission: PromptSubmission) => void submissions.push(submission),
+ onPromptDraftApplied() {},
+ onModelConfigChange: () => "",
+ onPermissionSettingsChange: () => "",
+ onPlanModeChange() {},
+ onInterrupt() {},
+ };
+ const h = await terminal(t, React.createElement(PromptInput, props), 24);
+ await h.send("hello");
+ assert.match(h.frame(), /> hello/);
+ assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
+
+ await h.send(" world");
+ assert.match(h.frame(), /> hello\s*\n {2}world/);
+ assert.equal((h.rawFrame().match(/\u001B\[7m/g) ?? []).length, 1);
+ for (const letter of ["d", "l", "r", "o", "w"]) {
+ await h.send("\x1b[D");
+ assert.match(h.frame(), /> hello\s*\n {2}world/);
+ assert.ok(h.rawFrame().includes(`\x1b[7m${letter}\x1b[27m`));
+ }
+ await h.send("\x1b[D");
+ assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
+ await h.send("\x1b[C");
+ assert.ok(h.rawFrame().includes("\x1b[7mw\x1b[27m"));
+ await h.send("\x1b[C");
+ assert.ok(h.rawFrame().includes("\x1b[7mo\x1b[27m"));
+
+ h.rerender(React.createElement(PromptInput, { ...props, screenWidth: 24 }));
+ await h.send("\x1b[C");
+ assert.match(h.frame(), /> hello world/);
+ assert.doesNotMatch(h.rawFrame(), /\u001B\[7m/);
+ await h.send("\r");
+ assert.equal(submissions[0]?.text, "hello world");
+});
diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx
index 71030993..af01b027 100644
--- a/packages/cli/src/ui/components/MessageView/index.tsx
+++ b/packages/cli/src/ui/components/MessageView/index.tsx
@@ -172,7 +172,7 @@ function PromptEchoLine({
{"> "}
-
+
{text}
{attachmentCount > 0 ? {` 📎 ${attachmentCount} image attachment(s)`} : null}
diff --git a/packages/cli/src/ui/hooks/cursor.ts b/packages/cli/src/ui/hooks/cursor.ts
index 677fa810..57b028cd 100644
--- a/packages/cli/src/ui/hooks/cursor.ts
+++ b/packages/cli/src/ui/hooks/cursor.ts
@@ -1,4 +1,6 @@
import { useCursor, useBoxMetrics } from "ink";
+import stringWidth from "string-width";
+import wrapAnsi from "wrap-ansi";
import { useLayoutEffect, useState } from "react";
import type { RefObject } from "react";
import type { DOMElement } from "ink";
@@ -52,88 +54,65 @@ export function getPromptCursorPlacement(
screenWidth: number,
initialColumn = 0
): CursorPlacement {
- const width = Math.max(1, screenWidth);
- const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
- const beforeCursor = state.text.slice(0, cursor);
- const cursorPosition = measureTextPosition(beforeCursor, width, initialColumn);
- return { row: cursorPosition.row, column: cursorPosition.column };
+ const { row, column } = measurePromptCursor(state, screenWidth, initialColumn);
+ return { row, column };
}
export function isPromptCursorAtWrapBoundary(state: PromptBufferState, screenWidth: number): boolean {
- const width = Math.max(1, screenWidth);
- const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
- const currentLineStart = state.text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
- const currentLineBeforeCursor = state.text.slice(currentLineStart, cursor);
- return measureTextPosition(currentLineBeforeCursor, width, 0).row > 0;
+ return measurePromptCursor(state, screenWidth).softWrapped;
}
-function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } {
+const graphemes = new Intl.Segmenter();
+
+function measurePromptCursor(state: PromptBufferState, screenWidth: number, initialColumn = 0) {
+ const width = Math.max(1, screenWidth);
+ const indent = Math.max(0, Math.min(initialColumn, width - 1));
+ const contentWidth = width - indent;
+ const cursor = Math.max(0, Math.min(state.cursor, state.text.length));
+ let sourceStart = 0;
let row = 0;
- let column = Math.min(initialColumn, width - 1);
- let pendingWrap = false;
-
- for (const char of Array.from(text)) {
- if (char === "\n") {
- row++;
- column = Math.min(initialColumn, width - 1);
- pendingWrap = false;
- continue;
- }
- if (pendingWrap) {
- row++;
- column = Math.min(initialColumn, width - 1);
- pendingWrap = false;
+ for (const line of state.text.split("\n")) {
+ // wrap-ansi normalizes Unicode and expands tabs. Keep the cursor's source
+ // offset while applying those transformations, snapping inside a grapheme
+ // to its first cell rather than measuring partial emoji or accents.
+ let normalized = "";
+ let offset = 0;
+ for (const { segment, index } of graphemes.segment(line)) {
+ const value = segment === "\t" ? " ".repeat(8 - (stringWidth(normalized) % 8)) : segment.normalize();
+ normalized += value;
+ if (sourceStart + index + segment.length <= cursor) {
+ offset = normalized.length;
+ }
}
- const charColumns = textWidth(char);
- if (column + charColumns > width) {
- row++;
- column = Math.min(initialColumn, width - 1);
- }
- column += charColumns;
- if (column >= width) {
- column = width;
- pendingWrap = true;
+ // Match Ink's Text wrap="wrap", including preserved spaces and hard
+ // breaks for oversized words. The suffix matters even before the cursor:
+ // it can move the entire word containing that cursor onto the next row.
+ const rows = wrapAnsi(normalized, contentWidth, { trim: false, hard: true }).split("\n");
+ if (cursor > sourceStart + line.length) {
+ row += rows.length;
+ sourceStart += line.length + 1;
+ continue;
}
- }
-
- if (pendingWrap) {
- return { row: row + 1, column: Math.min(initialColumn, width - 1) };
- }
-
- return { row, column };
-}
-function textWidth(value: string): number {
- let width = 0;
- for (const char of Array.from(value.normalize())) {
- width += characterWidth(char);
+ let wrappedRow = 0;
+ for (; wrappedRow < rows.length - 1; wrappedRow++) {
+ const length = rows[wrappedRow]!.length;
+ if (offset < length) break;
+ offset -= length;
+ }
+ const column = stringWidth(rows[wrappedRow]!.slice(0, offset));
+ // An inline cursor reserves the next row at an exact-width line end.
+ const pendingWrap = column >= contentWidth;
+ return {
+ row: row + wrappedRow + (pendingWrap ? 1 : 0),
+ column: indent + (pendingWrap ? 0 : column),
+ softWrapped: wrappedRow > 0 || pendingWrap,
+ };
}
- return width;
-}
-function characterWidth(char: string): number {
- const codePoint = char.codePointAt(0) ?? 0;
- if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
- return 0;
- }
- if (codePoint >= 0x300 && codePoint <= 0x36f) {
- return 0;
- }
- if (
- (codePoint >= 0x1100 && codePoint <= 0x115f) ||
- (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
- (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
- (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
- (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
- (codePoint >= 0xff00 && codePoint <= 0xff60) ||
- (codePoint >= 0xffe0 && codePoint <= 0xffe6)
- ) {
- return 2;
- }
- return 1;
+ return { row, column: indent, softWrapped: false };
}
export function usePromptTerminalCursor(
diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx
index 7392da36..4a26d9cf 100644
--- a/packages/cli/src/ui/views/PromptInput.tsx
+++ b/packages/cli/src/ui/views/PromptInput.tsx
@@ -131,6 +131,7 @@ type Props = {
};
const PROMPT_PREFIX_WIDTH = 2;
+const promptGraphemes = new Intl.Segmenter();
const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement {
return (
@@ -973,7 +974,7 @@ export const PromptInput = React.memo(function PromptInput({
>
-
+
{search?.preview !== null && search?.preview !== undefined ? (
) : (
@@ -1285,7 +1286,7 @@ function renderTextSegmentWithCursor(
const cursorRel = cursor - start; // relative cursor position inside this segment
// Cursor not in this segment – just return the text.
- if (cursorRel < 0 || cursorRel > segText.length) {
+ if (cursorRel < 0 || cursorRel > segText.length || (cursorRel === segText.length && end < text.length)) {
return highlighted ? chalk.yellow(segText) : segText;
}
@@ -1295,17 +1296,19 @@ function renderTextSegmentWithCursor(
}
// Cursor is somewhere inside the segment.
- const at = segText[cursorRel];
+ const grapheme = promptGraphemes.segment(segText).containing(cursorRel)!;
+ const at = grapheme.segment;
+ const cellStart = grapheme.index;
if (at === "\n") {
// Render newline as a space in the cursor cell, then output the actual newline.
- const before = segText.slice(0, cursorRel);
- const after = segText.slice(cursorRel + 1);
+ const before = segText.slice(0, cellStart);
+ const after = segText.slice(cellStart + at.length);
return before + renderCursorCell(" ") + "\n" + after;
}
- const before = segText.slice(0, cursorRel);
- const after = segText.slice(cursorRel + 1);
+ const before = segText.slice(0, cellStart);
+ const after = segText.slice(cellStart + at.length);
if (highlighted) {
return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after);
}