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/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 48eb659a..c81d2237 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";
@@ -85,6 +87,7 @@ type Props = {
screenWidth: number;
promptHistory: string[];
busy: boolean;
+ cursorLayoutKey?: string;
loadingText?: string | null;
disabled?: boolean;
placeholder?: string;
@@ -97,26 +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}
+
+ {"> "}
);
});
@@ -128,6 +117,7 @@ export const PromptInput = React.memo(function PromptInput({
screenWidth,
promptHistory,
busy,
+ cursorLayoutKey,
loadingText,
disabled,
placeholder,
@@ -141,6 +131,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([]);
@@ -201,28 +192,47 @@ 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,
[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,
+ cursorLayoutKey ?? "default",
+ imageUrls.length,
+ selectedSkills.map((skill) => skill.name).join("\u001F"),
+ ].join("\u001E"),
+ [cursorLayoutKey, 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,
+ !busy && usePositionedCursor,
+ promptCursorLayoutKey
+ );
+ useHiddenTerminalCursor(stdout, !disabled && (busy || !terminalCursorActive));
const refreshFileMentionItems = React.useCallback(() => {
setFileMentionItems(scanFileMentionItems(projectRoot));
@@ -764,9 +774,17 @@ export const PromptInput = React.memo(function PromptInput({
borderRight={false}
borderDimColor
>
-
-
- {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)}
+
+
+
+ {renderBufferWithCursor(
+ buffer,
+ !disabled && hasTerminalFocus,
+ placeholder,
+ pastesRef.current,
+ !busy && !terminalCursorActive
+ )}
+
{inlineHint ? {inlineHint} : null}
@@ -885,24 +903,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 +932,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;