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); }