From 7fa00dcf4d1cb5be1daadba1103fb19f1808e9a0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 8 Jun 2026 23:43:13 +0800 Subject: [PATCH 1/2] fix(ui): stabilize prompt cursor wrapping --- src/tests/message-view.test.ts | 23 +++ src/tests/prompt-input-keys.test.ts | 83 ++++++++-- src/ui/components/MessageView/index.tsx | 58 ++++--- src/ui/hooks/cursor.ts | 204 +++++++++++------------- src/ui/hooks/index.ts | 2 + src/ui/index.ts | 8 +- src/ui/views/PromptInput.tsx | 53 ++++-- 7 files changed, 274 insertions(+), 157 deletions(-) diff --git a/src/tests/message-view.test.ts b/src/tests/message-view.test.ts index ba1a9152..7d6b781c 100644 --- a/src/tests/message-view.test.ts +++ b/src/tests/message-view.test.ts @@ -1,6 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import React from "react"; +import { renderToString } from "ink"; import { parseDiffPreview } from "../ui"; +import { MessageView, getPromptEchoContentWidth } from "../ui/components/MessageView"; import { buildThinkingSummary, formatBashStatusParams, @@ -119,6 +122,26 @@ test("renderMessageToStdout shows (no content) for empty user messages", () => { assert.ok(output.includes("(no content)")); }); +test("MessageView echoes submitted user prompts with live prompt wrapping width", () => { + assert.equal(getPromptEchoContentWidth(8), 6); + + const msg = makeSessionMessage({ role: "user", content: "abcdefg" }); + const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); + + assert.equal(output, "> abcdef\n g\n"); +}); + +test("MessageView echoes model changes with submitted prompt wrapping", () => { + const msg = makeSessionMessage({ + role: "system", + content: "abcdefgh", + meta: { isModelChange: true }, + }); + const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); + + assert.equal(output, "> abcdef\n gh\n"); +}); + test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { const msg = makeSessionMessage({ role: "assistant", content: "Here is the fix" }); const output = renderMessageToStdout(msg, RawMode.Raw); diff --git a/src/tests/prompt-input-keys.test.ts b/src/tests/prompt-input-keys.test.ts index 4ca564f9..a8999b6b 100644 --- a/src/tests/prompt-input-keys.test.ts +++ b/src/tests/prompt-input-keys.test.ts @@ -13,8 +13,10 @@ import { formatSelectedSkillsStatus, getPromptCursorPlacement, getPromptReturnKeyAction, + isPromptCursorAtWrapBoundary, isClearImageAttachmentsShortcut, removeCurrentSlashToken, + resolvePromptTerminalCursorPosition, toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, @@ -294,24 +296,83 @@ test("renderBufferWithCursor styles exactly one simulated cursor", () => { assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); }); -test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 7 }); +test("renderBufferWithCursor can suppress the simulated cursor for real terminal cursor mode", () => { + assert.equal( + (renderBufferWithCursor({ text: "", cursor: 0 }, true, undefined, undefined, false).match(ANSI_RE) ?? []).length, + 0 + ); + assert.equal( + stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything", undefined, false)), + " Ask anything" + ); + assert.equal( + (renderBufferWithCursor({ text: "hello", cursor: 1 }, true, undefined, undefined, false).match(ANSI_RE) ?? []) + .length, + 0 + ); + assert.equal( + stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true, undefined, undefined, false)), + "hello\n " + ); +}); + +test("getPromptCursorPlacement targets an Ink-relative prompt cell", () => { + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80); + assert.deepEqual(placement, { row: 0, column: 5 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 2 }); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80); + assert.deepEqual(placement, { row: 1, column: 0 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); - assert.equal(placement.column, 6); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80); + assert.equal(placement.column, 4); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); - assert.deepEqual(middle, { rowsUp: 4, column: 4 }); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80); + assert.deepEqual(placement, { row: 1, column: 5 }); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80); + assert.deepEqual(middle, { row: 0, column: 2 }); +}); + +test("getPromptCursorPlacement accounts for wrapped input rows", () => { + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 5); + assert.deepEqual(placement, { row: 1, column: 0 }); + const cursorBeforeWrappedChar = getPromptCursorPlacement({ text: "hello!", cursor: 5 }, 5); + assert.deepEqual(cursorBeforeWrappedChar, { row: 1, column: 0 }); + const secondLine = getPromptCursorPlacement({ text: "hello!", cursor: 6 }, 5); + assert.deepEqual(secondLine, { row: 1, column: 1 }); +}); + +test("isPromptCursorAtWrapBoundary detects hard-wrapped cursor positions", () => { + assert.equal(isPromptCursorAtWrapBoundary({ text: "hell", cursor: 4 }, 5), false); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello", cursor: 5 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello!", cursor: 6 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello world", cursor: 6 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\n", cursor: 6 }, 5), false); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\nworld", cursor: 11 }, 5), true); +}); + +test("resolvePromptTerminalCursorPosition requires matching measured layout", () => { + const placement = { row: 1, column: 4 }; + const origin = { layoutKey: "skills:1", left: 2, top: 3 }; + + assert.deepEqual(resolvePromptTerminalCursorPosition(placement, true, "skills:1", origin), { x: 6, y: 4 }); + assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:0", origin), undefined); + assert.equal(resolvePromptTerminalCursorPosition(placement, false, "skills:1", origin), undefined); + assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:1", null), undefined); +}); + +test("resolvePromptTerminalCursorPosition clamps negative terminal cells", () => { + assert.deepEqual( + resolvePromptTerminalCursorPosition({ row: 0, column: 1 }, true, "current", { + layoutKey: "current", + left: -5, + top: -1, + }), + { x: 0, y: 0 } + ); }); diff --git a/src/ui/components/MessageView/index.tsx b/src/ui/components/MessageView/index.tsx index 9c315516..66df9625 100644 --- a/src/ui/components/MessageView/index.tsx +++ b/src/ui/components/MessageView/index.tsx @@ -12,6 +12,8 @@ import { import type { DiffPreviewLine, MessageViewProps } from "./types"; import { RawMode, useRawModeContext } from "../../contexts"; +const PROMPT_ECHO_PREFIX_WIDTH = 2; + export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { const { mode } = useRawModeContext(); if (!message.visible) { @@ -21,17 +23,11 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (message.role === "user") { const text = message.content || "(no content)"; return ( - - - {`>`} - - - {text} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} - - + ); } @@ -109,16 +105,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps if (message.role === "system") { // Render model change messages in the same style as user commands. if (message.meta?.isModelChange) { - return ( - - - {`>`} - - - {message.content} - - - ); + return ; } if (message.meta?.skill) { @@ -143,6 +130,35 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps return null; } +export function getPromptEchoContentWidth(width: number): number { + return Math.max(1, width - PROMPT_ECHO_PREFIX_WIDTH); +} + +function PromptEchoLine({ + text, + width, + attachmentCount = 0, +}: { + text: string; + width: number; + attachmentCount?: number; +}): React.ReactElement { + const contentWidth = getPromptEchoContentWidth(width); + return ( + + + {"> "} + + + + {text} + + {attachmentCount > 0 ? {` 📎 ${attachmentCount} image attachment(s)`} : null} + + + ); +} + function StatusLine({ bulletColor, name, diff --git a/src/ui/hooks/cursor.ts b/src/ui/hooks/cursor.ts index 07cc5779..677fa810 100644 --- a/src/ui/hooks/cursor.ts +++ b/src/ui/hooks/cursor.ts @@ -1,28 +1,19 @@ -import { useLayoutEffect, useRef } from "react"; +import { useCursor, useBoxMetrics } from "ink"; +import { useLayoutEffect, useState } from "react"; +import type { RefObject } from "react"; +import type { DOMElement } from "ink"; import type { PromptBufferState } from "../core/prompt-buffer"; -type CursorPlacement = { - rowsUp: number; +export type CursorPlacement = { + row: number; column: number; }; -type WriteFn = ( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void -) => boolean; - -function cursorUp(rows: number): string { - return rows > 0 ? `\u001B[${rows}A` : ""; -} - -function cursorDown(rows: number): string { - return rows > 0 ? `\u001B[${rows}B` : ""; -} - -function cursorForward(columns: number): string { - return columns > 0 ? `\u001B[${columns}C` : ""; -} +export type PromptCursorOrigin = { + layoutKey: string; + left: number; + top: number; +}; function showCursor(): string { return "\u001B[?25h"; @@ -59,44 +50,42 @@ export function disableTerminalExtendedKeys(): string { export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, - prefixWidth: number, - footerText: string + 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 at = state.text[cursor]; - const displayText = - beforeCursor + - (typeof at === "undefined" || at === "\n" ? " " : at) + - (at === "\n" ? "\n" : "") + - (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); - - const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); - const promptRows = measureTextRows(displayText, width, prefixWidth); - const footerRows = 1 + measureTextRows(footerText, width, 0); - - return { - rowsUp: promptRows - 1 - cursorPosition.row + footerRows + 1, - column: cursorPosition.column, - }; + const cursorPosition = measureTextPosition(beforeCursor, width, initialColumn); + return { row: cursorPosition.row, column: cursorPosition.column }; } -function measureTextRows(text: string, width: number, initialColumn: number): number { - return measureTextPosition(text, width, initialColumn).row + 1; +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; } function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { 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; + } + const charColumns = textWidth(char); if (column + charColumns > width) { row++; @@ -104,11 +93,15 @@ function measureTextPosition(text: string, width: number, initialColumn: number) } column += charColumns; if (column >= width) { - row++; - column = Math.min(initialColumn, width - 1); + column = width; + pendingWrap = true; } } + if (pendingWrap) { + return { row: row + 1, column: Math.min(initialColumn, width - 1) }; + } + return { row, column }; } @@ -144,90 +137,79 @@ function characterWidth(char: string): number { } export function usePromptTerminalCursor( - stdout: NodeJS.WriteStream | undefined, + targetRef: RefObject, placement: CursorPlacement, - isActive: boolean -): void { - const directWriteRef = useRef<((data: string) => void) | null>(null); - const activePlacementRef = useRef(null); - const lastPlacementRef = useRef(null); - const unmountingRef = useRef(false); + isActive: boolean, + layoutKey = "default" +): boolean { + const { setCursorPosition } = useCursor(); + const metrics = useBoxMetrics(targetRef as RefObject); + const [origin, setOrigin] = useState(null); useLayoutEffect(() => { - if (!stdout?.isTTY) { + if (!isActive || !metrics.hasMeasured) { return; } - const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; - const originalWrite = stream.write; - const directWrite = (data: string) => { - originalWrite.call(stdout, data); - }; - const restorePromptCursor = () => { - if (unmountingRef.current) { - return; + const absolutePosition = getAbsoluteElementPosition(targetRef.current); + setOrigin((previous) => { + if (!absolutePosition) { + return previous === null ? previous : null; } - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; + + if ( + previous?.layoutKey === layoutKey && + previous.left === absolutePosition.left && + previous.top === absolutePosition.top + ) { + return previous; } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - // Schedule a deferred re-position in case the layout effect does not - // re-run (e.g. a dropdown closed without changing the buffer). - Promise.resolve().then(() => { - if (unmountingRef.current || activePlacementRef.current) { - return; - } - const latest = directWriteRef.current; - const p = lastPlacementRef.current; - if (latest && p) { - latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); - activePlacementRef.current = p; - } - }); - }; - const patchedWrite: WriteFn = (...args) => { - restorePromptCursor(); - return originalWrite.apply(stdout, args); - }; - directWriteRef.current = directWrite; - stream.write = patchedWrite; + return { + layoutKey, + left: absolutePosition.left, + top: absolutePosition.top, + }; + }); + }, [isActive, layoutKey, metrics.hasMeasured, metrics.height, metrics.left, metrics.top, metrics.width, targetRef]); + + const cursorPosition = resolvePromptTerminalCursorPosition(placement, isActive, layoutKey, origin); + setCursorPosition(cursorPosition); + return cursorPosition !== undefined; +} - return () => { - restorePromptCursor(); - stream.write = originalWrite; - directWriteRef.current = null; - }; - }, [stdout]); +export function resolvePromptTerminalCursorPosition( + placement: CursorPlacement, + isActive: boolean, + layoutKey: string, + origin: PromptCursorOrigin | null +): { x: number; y: number } | undefined { + if (!isActive || origin?.layoutKey !== layoutKey) { + return undefined; + } - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } + return { + x: Math.max(0, Math.round(origin.left + placement.column)), + y: Math.max(0, Math.round(origin.top + placement.row)), + }; +} - unmountingRef.current = false; - const directWrite = directWriteRef.current; - if (!directWrite) { - return; - } +function getAbsoluteElementPosition(element: DOMElement | null): { left: number; top: number } | null { + let current: DOMElement | undefined = element ?? undefined; + let left = 0; + let top = 0; - directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); - activePlacementRef.current = placement; - lastPlacementRef.current = placement; + while (current) { + const layout = current.yogaNode?.getComputedLayout(); + if (!layout) { + return null; + } + left += layout.left; + top += layout.top; + current = current.parentNode; + } - return () => { - unmountingRef.current = true; - lastPlacementRef.current = null; - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - }, [isActive, placement, stdout]); + return { left, top }; } export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { diff --git a/src/ui/hooks/index.ts b/src/ui/hooks/index.ts index 86245b65..226a6e98 100644 --- a/src/ui/hooks/index.ts +++ b/src/ui/hooks/index.ts @@ -8,6 +8,8 @@ export { usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, + resolvePromptTerminalCursorPosition, } from "./cursor"; export { usePasteHandling } from "./usePasteHandling"; diff --git a/src/ui/index.ts b/src/ui/index.ts index ae1109ad..2504bbd8 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -6,7 +6,13 @@ import { export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; export { buildPromptDraftFromSessionMessage } from "./utils"; -export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./hooks/cursor"; +export { + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, + getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, + resolvePromptTerminalCursorPosition, +} from "./hooks/cursor"; export { default as AppContainer } from "./views/AppContainer"; export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt"; export { MessageView } from "./components"; diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index 48eb659a..f550afa9 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; +import type { DOMElement } from "ink"; import chalk from "chalk"; import { ARGS_SEPARATOR } from "../constants"; import { @@ -48,6 +49,7 @@ import { usePasteHandling, useHistoryNavigation, getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, usePromptTerminalCursor, } from "../hooks"; import type { InputKey } from "../hooks"; @@ -98,6 +100,7 @@ type Props = { }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const PROMPT_PREFIX_WIDTH = 2; const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { const [spinnerIndex, setSpinnerIndex] = useState(0); @@ -141,6 +144,7 @@ export const PromptInput = React.memo(function PromptInput({ }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); + const inputTextRef = useRef(null); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -212,17 +216,28 @@ export const PromptInput = React.memo(function PromptInput({ () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] ); + const inputContentWidth = Math.max(1, screenWidth - PROMPT_PREFIX_WIDTH); const cursorPlacement = useMemo( - () => getPromptCursorPlacement(buffer, screenWidth, 2, footerText), - [buffer, footerText, screenWidth] + () => getPromptCursorPlacement(buffer, inputContentWidth), + [buffer, inputContentWidth] + ); + const useInlineCursor = isPromptCursorAtWrapBoundary(buffer, inputContentWidth); + const usePositionedCursor = !disabled && hasTerminalFocus && !showFooterText && stdout.isTTY && !useInlineCursor; + const promptCursorLayoutKey = useMemo( + () => [screenWidth, imageUrls.length, selectedSkills.map((skill) => skill.name).join("\u001F")].join("\u001E"), + [imageUrls.length, screenWidth, selectedSkills] ); - const usePositionedCursor = !disabled && hasTerminalFocus && !showFooterText; useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); useBracketedPaste(stdout, !disabled); - usePromptTerminalCursor(stdout, cursorPlacement, usePositionedCursor); - useHiddenTerminalCursor(stdout, !disabled && !usePositionedCursor); + const terminalCursorActive = usePromptTerminalCursor( + inputTextRef, + cursorPlacement, + usePositionedCursor, + promptCursorLayoutKey + ); + useHiddenTerminalCursor(stdout, !disabled && !terminalCursorActive); const refreshFileMentionItems = React.useCallback(() => { setFileMentionItems(scanFileMentionItems(projectRoot)); @@ -765,8 +780,16 @@ export const PromptInput = React.memo(function PromptInput({ borderDimColor > - - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)} + + + {renderBufferWithCursor( + buffer, + !disabled && hasTerminalFocus, + placeholder, + pastesRef.current, + !terminalCursorActive + )} + {inlineHint ? {inlineHint} : null} @@ -885,24 +908,28 @@ export function renderBufferWithCursor( state: PromptBufferState, isFocused: boolean, placeholder?: string, - validPastes?: Map + validPastes?: Map, + showSimulatedCursor = true ): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); const validIds = validPastes ?? new Map(); if (text.length === 0 && placeholder) { - if (!isFocused) { + if (!isFocused || !showSimulatedCursor) { return chalk.dim(` ${placeholder}`); } return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } if (text.length === 0) { - return isFocused ? renderCursorCell(" ") : ""; + if (!isFocused) { + return ""; + } + return showSimulatedCursor ? renderCursorCell(" ") : " "; } - if (!isFocused) { + if (!isFocused || !showSimulatedCursor) { return highlightPasteMarkersInText(text, validIds); } @@ -910,7 +937,7 @@ export function renderBufferWithCursor( } function highlightPasteMarkersInText(s: string, validIds: Map): string { - if (!s.includes("[paste #")) return s; + if (!s.includes("[paste #")) return s.endsWith("\n") ? `${s} ` : s; PASTE_MARKER_REGEX.lastIndex = 0; let result = ""; let pos = 0; From 4cf5cc0bd86670ded58c9a8c4a737060266471cd Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Tue, 9 Jun 2026 11:28:11 +0800 Subject: [PATCH 2/2] fix(ui): improve prompt cursor wrapping and status line display --- src/ui/views/App.tsx | 71 +++++++++++++++++++++++++++++++++--- src/ui/views/PromptInput.tsx | 49 +++++++++++-------------- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/src/ui/views/App.tsx b/src/ui/views/App.tsx index 1579848f..bc12962a 100644 --- a/src/ui/views/App.tsx +++ b/src/ui/views/App.tsx @@ -48,12 +48,47 @@ import { SessionManager } from "../../session"; type View = "chat" | "session-list" | "undo" | "mcp-status"; +const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + type AppProps = { projectRoot: string; initialPrompt?: string; onRestart?: () => void; }; +const StatusLine = React.memo(function StatusLine({ + busy, + text, +}: { + busy: boolean; + text?: string; +}): React.ReactElement { + const [spinnerIndex, setSpinnerIndex] = useState(0); + + useEffect(() => { + if (!busy) { + setSpinnerIndex(0); + return; + } + + const timer = setInterval(() => { + setSpinnerIndex((index) => (index + 1) % STATUS_SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(timer); + }, [busy]); + + return ( + + {busy ? ( + + {STATUS_SPINNER_FRAMES[spinnerIndex]} + + ) : null} + {text ? {text} : null} + + ); +}); + function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); @@ -641,6 +676,35 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl } return messages; }, [mode, showWelcome, view, messages, welcomeItem]); + const promptCursorLayoutKey = useMemo(() => { + const lastStaticItem = staticItems.at(-1); + return [ + view, + busy ? "busy" : "idle", + statusLine, + errorLine ?? "", + showProcessStdout ? "stdout" : "main", + activeStatus ?? "", + staticItems.length, + lastStaticItem?.id ?? "", + lastStaticItem?.updateTime ?? "", + shouldShowQuestionPrompt ? (pendingQuestion?.messageId ?? "") : "", + activeAskPermissions?.length ?? 0, + pendingPermissionReply ? "pending-permission-reply" : "no-pending-permission-reply", + ].join("\u001E"); + }, [ + activeAskPermissions, + activeStatus, + busy, + errorLine, + pendingPermissionReply, + pendingQuestion, + shouldShowQuestionPrompt, + showProcessStdout, + staticItems, + statusLine, + view, + ]); const handleQuestionAnswers = useCallback( (answers: AskUserQuestionAnswers) => { @@ -724,11 +788,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl ); }} - {statusLine ? ( - - {statusLine} - - ) : null} + {busy || statusLine ? : null} {errorLine ? ( Error: {errorLine} @@ -802,6 +862,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl modelConfig={resolvedSettings} promptHistory={promptHistory} busy={busy} + cursorLayoutKey={promptCursorLayoutKey} loadingText={loadingText} runningProcesses={runningProcesses} promptDraft={promptDraft} diff --git a/src/ui/views/PromptInput.tsx b/src/ui/views/PromptInput.tsx index f550afa9..c81d2237 100644 --- a/src/ui/views/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -87,6 +87,7 @@ type Props = { screenWidth: number; promptHistory: string[]; busy: boolean; + cursorLayoutKey?: string; loadingText?: string | null; disabled?: boolean; placeholder?: string; @@ -99,27 +100,12 @@ type Props = { onToggleProcessStdout?: () => void; }; -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const PROMPT_PREFIX_WIDTH = 2; -const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { - const [spinnerIndex, setSpinnerIndex] = useState(0); - - useEffect(() => { - if (!busy) { - setSpinnerIndex(0); - return; - } - const timer = setInterval(() => { - setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); - }, 80); - return () => clearInterval(timer); - }, [busy]); - - const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; +const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement { return ( - - {prefix} + + {"> "} ); }); @@ -131,6 +117,7 @@ export const PromptInput = React.memo(function PromptInput({ screenWidth, promptHistory, busy, + cursorLayoutKey, loadingText, disabled, placeholder, @@ -205,12 +192,14 @@ export const PromptInput = React.memo(function PromptInput({ : hasExpandedRegions ? " · ctrl+o collapse" : ""; + const busyStatusText = + loadingText && loadingText.trim() + ? `${loadingText}${processOrPasteHint}` + : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`; const footerText = statusMessage ? statusMessage : busy - ? loadingText && loadingText.trim() - ? `${loadingText}${processOrPasteHint}` - : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}` + ? busyStatusText : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; const showFooterText = useMemo( () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, @@ -225,8 +214,14 @@ export const PromptInput = React.memo(function PromptInput({ const useInlineCursor = isPromptCursorAtWrapBoundary(buffer, inputContentWidth); const usePositionedCursor = !disabled && hasTerminalFocus && !showFooterText && stdout.isTTY && !useInlineCursor; const promptCursorLayoutKey = useMemo( - () => [screenWidth, imageUrls.length, selectedSkills.map((skill) => skill.name).join("\u001F")].join("\u001E"), - [imageUrls.length, screenWidth, selectedSkills] + () => + [ + screenWidth, + cursorLayoutKey ?? "default", + imageUrls.length, + selectedSkills.map((skill) => skill.name).join("\u001F"), + ].join("\u001E"), + [cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills] ); useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); @@ -234,10 +229,10 @@ export const PromptInput = React.memo(function PromptInput({ const terminalCursorActive = usePromptTerminalCursor( inputTextRef, cursorPlacement, - usePositionedCursor, + !busy && usePositionedCursor, promptCursorLayoutKey ); - useHiddenTerminalCursor(stdout, !disabled && !terminalCursorActive); + useHiddenTerminalCursor(stdout, !disabled && (busy || !terminalCursorActive)); const refreshFileMentionItems = React.useCallback(() => { setFileMentionItems(scanFileMentionItems(projectRoot)); @@ -779,7 +774,7 @@ export const PromptInput = React.memo(function PromptInput({ borderRight={false} borderDimColor > - + {renderBufferWithCursor( @@ -787,7 +782,7 @@ export const PromptInput = React.memo(function PromptInput({ !disabled && hasTerminalFocus, placeholder, pastesRef.current, - !terminalCursorActive + !busy && !terminalCursorActive )} {inlineHint ? {inlineHint} : null}