Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/cli/src/tests/message-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/tests/prompt-input-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 });
});
52 changes: 52 additions & 0 deletions packages/cli/src/tests/prompt-input-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/MessageView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function PromptEchoLine({
<Text color="#229ac3">{"> "}</Text>
</Box>
<Box flexGrow={1} flexShrink={1} width={contentWidth}>
<Text color="#229ac3" wrap="hard">
<Text color="#229ac3" wrap="wrap">
{text}
</Text>
{attachmentCount > 0 ? <Text color="#229ac3">{` 📎 ${attachmentCount} image attachment(s)`}</Text> : null}
Expand Down
117 changes: 48 additions & 69 deletions packages/cli/src/ui/hooks/cursor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 10 additions & 7 deletions packages/cli/src/ui/views/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -973,7 +974,7 @@ export const PromptInput = React.memo(function PromptInput({
>
<PromptPrefixLine />
<Box ref={inputTextRef} flexGrow={1} flexShrink={1} width={inputContentWidth}>
<Text wrap="hard">
<Text wrap="wrap">
{search?.preview !== null && search?.preview !== undefined ? (
<HistoryPreview text={search.preview} query={search.query} />
) : (
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
}
Expand Down
Loading