From a0ae5226e3f4e4c30d84e2f3b7c184e598a75404 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:12:49 -0800 Subject: [PATCH] fix(ui): wrap prompt words without misplacing the cursor The input and prompt echoes now use word wrapping. Cursor measurement wraps the complete line with wrap-ansi and maps source offsets through grapheme normalization and tab expansion. Inline cursor rendering highlights whole graphemes and avoids duplicate cursor cells at segment boundaries. Regression tests cover wrapping, cursor movement, resizing, and submission of the original buffer. Fixes #327 --- packages/cli/src/tests/message-view.test.ts | 8 ++ .../cli/src/tests/prompt-input-keys.test.ts | 65 ++++++++++ .../cli/src/tests/prompt-input-wrap.test.ts | 52 ++++++++ .../src/ui/components/MessageView/index.tsx | 2 +- packages/cli/src/ui/hooks/cursor.ts | 117 +++++++----------- packages/cli/src/ui/views/PromptInput.tsx | 17 +-- 6 files changed, 184 insertions(+), 77 deletions(-) create mode 100644 packages/cli/src/tests/prompt-input-wrap.test.ts 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); }