Skip to content

Commit 766b057

Browse files
authored
Merge pull request #171 from lessweb/fix/prompt-cursor-wrapping
修复提示输入的换行、光标定位与 busy 状态显示
2 parents 726034f + 4cf5cc0 commit 766b057

8 files changed

Lines changed: 357 additions & 184 deletions

File tree

src/tests/message-view.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3+
import React from "react";
4+
import { renderToString } from "ink";
35
import { parseDiffPreview } from "../ui";
6+
import { MessageView, getPromptEchoContentWidth } from "../ui/components/MessageView";
47
import {
58
buildThinkingSummary,
69
formatBashStatusParams,
@@ -119,6 +122,26 @@ test("renderMessageToStdout shows (no content) for empty user messages", () => {
119122
assert.ok(output.includes("(no content)"));
120123
});
121124

125+
test("MessageView echoes submitted user prompts with live prompt wrapping width", () => {
126+
assert.equal(getPromptEchoContentWidth(8), 6);
127+
128+
const msg = makeSessionMessage({ role: "user", content: "abcdefg" });
129+
const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 });
130+
131+
assert.equal(output, "> abcdef\n g\n");
132+
});
133+
134+
test("MessageView echoes model changes with submitted prompt wrapping", () => {
135+
const msg = makeSessionMessage({
136+
role: "system",
137+
content: "abcdefgh",
138+
meta: { isModelChange: true },
139+
});
140+
const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 });
141+
142+
assert.equal(output, "> abcdef\n gh\n");
143+
});
144+
122145
test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => {
123146
const msg = makeSessionMessage({ role: "assistant", content: "Here is the fix" });
124147
const output = renderMessageToStdout(msg, RawMode.Raw);

src/tests/prompt-input-keys.test.ts

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
formatSelectedSkillsStatus,
1414
getPromptCursorPlacement,
1515
getPromptReturnKeyAction,
16+
isPromptCursorAtWrapBoundary,
1617
isClearImageAttachmentsShortcut,
1718
removeCurrentSlashToken,
19+
resolvePromptTerminalCursorPosition,
1820
toggleSkillSelection,
1921
renderBufferWithCursor,
2022
buildInitPromptSubmission,
@@ -294,24 +296,83 @@ test("renderBufferWithCursor styles exactly one simulated cursor", () => {
294296
assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2);
295297
});
296298

297-
test("getPromptCursorPlacement targets the prompt row above divider and footer", () => {
298-
const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send");
299-
assert.deepEqual(placement, { rowsUp: 3, column: 7 });
299+
test("renderBufferWithCursor can suppress the simulated cursor for real terminal cursor mode", () => {
300+
assert.equal(
301+
(renderBufferWithCursor({ text: "", cursor: 0 }, true, undefined, undefined, false).match(ANSI_RE) ?? []).length,
302+
0
303+
);
304+
assert.equal(
305+
stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything", undefined, false)),
306+
" Ask anything"
307+
);
308+
assert.equal(
309+
(renderBufferWithCursor({ text: "hello", cursor: 1 }, true, undefined, undefined, false).match(ANSI_RE) ?? [])
310+
.length,
311+
0
312+
);
313+
assert.equal(
314+
stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true, undefined, undefined, false)),
315+
"hello\n "
316+
);
317+
});
318+
319+
test("getPromptCursorPlacement targets an Ink-relative prompt cell", () => {
320+
const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80);
321+
assert.deepEqual(placement, { row: 0, column: 5 });
300322
});
301323

302324
test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => {
303-
const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send");
304-
assert.deepEqual(placement, { rowsUp: 3, column: 2 });
325+
const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80);
326+
assert.deepEqual(placement, { row: 1, column: 0 });
305327
});
306328

307329
test("getPromptCursorPlacement accounts for CJK character width", () => {
308-
const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send");
309-
assert.equal(placement.column, 6);
330+
const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80);
331+
assert.equal(placement.column, 4);
310332
});
311333

312334
test("getPromptCursorPlacement accounts for multiline buffer rows", () => {
313-
const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send");
314-
assert.deepEqual(placement, { rowsUp: 3, column: 7 });
315-
const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send");
316-
assert.deepEqual(middle, { rowsUp: 4, column: 4 });
335+
const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80);
336+
assert.deepEqual(placement, { row: 1, column: 5 });
337+
const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80);
338+
assert.deepEqual(middle, { row: 0, column: 2 });
339+
});
340+
341+
test("getPromptCursorPlacement accounts for wrapped input rows", () => {
342+
const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 5);
343+
assert.deepEqual(placement, { row: 1, column: 0 });
344+
const cursorBeforeWrappedChar = getPromptCursorPlacement({ text: "hello!", cursor: 5 }, 5);
345+
assert.deepEqual(cursorBeforeWrappedChar, { row: 1, column: 0 });
346+
const secondLine = getPromptCursorPlacement({ text: "hello!", cursor: 6 }, 5);
347+
assert.deepEqual(secondLine, { row: 1, column: 1 });
348+
});
349+
350+
test("isPromptCursorAtWrapBoundary detects hard-wrapped cursor positions", () => {
351+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hell", cursor: 4 }, 5), false);
352+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello", cursor: 5 }, 5), true);
353+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello!", cursor: 6 }, 5), true);
354+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello world", cursor: 6 }, 5), true);
355+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\n", cursor: 6 }, 5), false);
356+
assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\nworld", cursor: 11 }, 5), true);
357+
});
358+
359+
test("resolvePromptTerminalCursorPosition requires matching measured layout", () => {
360+
const placement = { row: 1, column: 4 };
361+
const origin = { layoutKey: "skills:1", left: 2, top: 3 };
362+
363+
assert.deepEqual(resolvePromptTerminalCursorPosition(placement, true, "skills:1", origin), { x: 6, y: 4 });
364+
assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:0", origin), undefined);
365+
assert.equal(resolvePromptTerminalCursorPosition(placement, false, "skills:1", origin), undefined);
366+
assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:1", null), undefined);
367+
});
368+
369+
test("resolvePromptTerminalCursorPosition clamps negative terminal cells", () => {
370+
assert.deepEqual(
371+
resolvePromptTerminalCursorPosition({ row: 0, column: 1 }, true, "current", {
372+
layoutKey: "current",
373+
left: -5,
374+
top: -1,
375+
}),
376+
{ x: 0, y: 0 }
377+
);
317378
});

src/ui/components/MessageView/index.tsx

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
import type { DiffPreviewLine, MessageViewProps } from "./types";
1313
import { RawMode, useRawModeContext } from "../../contexts";
1414

15+
const PROMPT_ECHO_PREFIX_WIDTH = 2;
16+
1517
export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null {
1618
const { mode } = useRawModeContext();
1719
if (!message.visible) {
@@ -21,17 +23,11 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
2123
if (message.role === "user") {
2224
const text = message.content || "(no content)";
2325
return (
24-
<Box marginLeft={1} marginBottom={1} flexDirection="row" marginY={0} flexGrow={1} gap={1}>
25-
<Box>
26-
<Text color="#229ac3">{`>`}</Text>
27-
</Box>
28-
<Box flexGrow={1}>
29-
<Text color="#229ac3">{text}</Text>
30-
{Array.isArray(message.contentParams) && message.contentParams.length > 0 ? (
31-
<Text color="#229ac3">{` 📎 ${message.contentParams.length} image attachment(s)`}</Text>
32-
) : null}
33-
</Box>
34-
</Box>
26+
<PromptEchoLine
27+
text={text}
28+
width={width}
29+
attachmentCount={Array.isArray(message.contentParams) ? message.contentParams.length : 0}
30+
/>
3531
);
3632
}
3733

@@ -109,16 +105,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
109105
if (message.role === "system") {
110106
// Render model change messages in the same style as user commands.
111107
if (message.meta?.isModelChange) {
112-
return (
113-
<Box marginY={0} marginLeft={1} marginBottom={1} flexGrow={1} flexDirection="row" gap={1}>
114-
<Box>
115-
<Text color="#229ac3">{`>`}</Text>
116-
</Box>
117-
<Box flexGrow={1} flexDirection="column">
118-
<Text color="#229ac3">{message.content}</Text>
119-
</Box>
120-
</Box>
121-
);
108+
return <PromptEchoLine text={message.content || ""} width={width} />;
122109
}
123110

124111
if (message.meta?.skill) {
@@ -143,6 +130,35 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps
143130
return null;
144131
}
145132

133+
export function getPromptEchoContentWidth(width: number): number {
134+
return Math.max(1, width - PROMPT_ECHO_PREFIX_WIDTH);
135+
}
136+
137+
function PromptEchoLine({
138+
text,
139+
width,
140+
attachmentCount = 0,
141+
}: {
142+
text: string;
143+
width: number;
144+
attachmentCount?: number;
145+
}): React.ReactElement {
146+
const contentWidth = getPromptEchoContentWidth(width);
147+
return (
148+
<Box marginBottom={1} marginY={0} width={Math.max(1, width)} flexDirection="row">
149+
<Box width={PROMPT_ECHO_PREFIX_WIDTH}>
150+
<Text color="#229ac3">{"> "}</Text>
151+
</Box>
152+
<Box flexGrow={1} flexShrink={1} width={contentWidth}>
153+
<Text color="#229ac3" wrap="hard">
154+
{text}
155+
</Text>
156+
{attachmentCount > 0 ? <Text color="#229ac3">{` 📎 ${attachmentCount} image attachment(s)`}</Text> : null}
157+
</Box>
158+
</Box>
159+
);
160+
}
161+
146162
function StatusLine({
147163
bulletColor,
148164
name,

0 commit comments

Comments
 (0)