Skip to content

Commit 4cf5cc0

Browse files
committed
fix(ui): improve prompt cursor wrapping and status line display
1 parent 7fa00dc commit 4cf5cc0

2 files changed

Lines changed: 88 additions & 32 deletions

File tree

src/ui/views/App.tsx

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,47 @@ import { SessionManager } from "../../session";
4848

4949
type View = "chat" | "session-list" | "undo" | "mcp-status";
5050

51+
const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
52+
5153
type AppProps = {
5254
projectRoot: string;
5355
initialPrompt?: string;
5456
onRestart?: () => void;
5557
};
5658

59+
const StatusLine = React.memo(function StatusLine({
60+
busy,
61+
text,
62+
}: {
63+
busy: boolean;
64+
text?: string;
65+
}): React.ReactElement {
66+
const [spinnerIndex, setSpinnerIndex] = useState(0);
67+
68+
useEffect(() => {
69+
if (!busy) {
70+
setSpinnerIndex(0);
71+
return;
72+
}
73+
74+
const timer = setInterval(() => {
75+
setSpinnerIndex((index) => (index + 1) % STATUS_SPINNER_FRAMES.length);
76+
}, 80);
77+
return () => clearInterval(timer);
78+
}, [busy]);
79+
80+
return (
81+
<Box>
82+
{busy ? (
83+
<Box marginRight={1}>
84+
<Text color="yellow">{STATUS_SPINNER_FRAMES[spinnerIndex]}</Text>
85+
</Box>
86+
) : null}
87+
{text ? <Text dimColor>{text}</Text> : null}
88+
</Box>
89+
);
90+
});
91+
5792
function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement {
5893
const { exit } = useApp();
5994
const { stdout, write } = useStdout();
@@ -641,6 +676,35 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl
641676
}
642677
return messages;
643678
}, [mode, showWelcome, view, messages, welcomeItem]);
679+
const promptCursorLayoutKey = useMemo(() => {
680+
const lastStaticItem = staticItems.at(-1);
681+
return [
682+
view,
683+
busy ? "busy" : "idle",
684+
statusLine,
685+
errorLine ?? "",
686+
showProcessStdout ? "stdout" : "main",
687+
activeStatus ?? "",
688+
staticItems.length,
689+
lastStaticItem?.id ?? "",
690+
lastStaticItem?.updateTime ?? "",
691+
shouldShowQuestionPrompt ? (pendingQuestion?.messageId ?? "") : "",
692+
activeAskPermissions?.length ?? 0,
693+
pendingPermissionReply ? "pending-permission-reply" : "no-pending-permission-reply",
694+
].join("\u001E");
695+
}, [
696+
activeAskPermissions,
697+
activeStatus,
698+
busy,
699+
errorLine,
700+
pendingPermissionReply,
701+
pendingQuestion,
702+
shouldShowQuestionPrompt,
703+
showProcessStdout,
704+
staticItems,
705+
statusLine,
706+
view,
707+
]);
644708

645709
const handleQuestionAnswers = useCallback(
646710
(answers: AskUserQuestionAnswers) => {
@@ -724,11 +788,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl
724788
);
725789
}}
726790
</Static>
727-
{statusLine ? (
728-
<Box>
729-
<Text dimColor>{statusLine}</Text>
730-
</Box>
731-
) : null}
791+
{busy || statusLine ? <StatusLine busy={busy} text={statusLine} /> : null}
732792
{errorLine ? (
733793
<Box>
734794
<Text color="red">Error: {errorLine}</Text>
@@ -802,6 +862,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl
802862
modelConfig={resolvedSettings}
803863
promptHistory={promptHistory}
804864
busy={busy}
865+
cursorLayoutKey={promptCursorLayoutKey}
805866
loadingText={loadingText}
806867
runningProcesses={runningProcesses}
807868
promptDraft={promptDraft}

src/ui/views/PromptInput.tsx

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type Props = {
8787
screenWidth: number;
8888
promptHistory: string[];
8989
busy: boolean;
90+
cursorLayoutKey?: string;
9091
loadingText?: string | null;
9192
disabled?: boolean;
9293
placeholder?: string;
@@ -99,27 +100,12 @@ type Props = {
99100
onToggleProcessStdout?: () => void;
100101
};
101102

102-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
103103
const PROMPT_PREFIX_WIDTH = 2;
104104

105-
const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement {
106-
const [spinnerIndex, setSpinnerIndex] = useState(0);
107-
108-
useEffect(() => {
109-
if (!busy) {
110-
setSpinnerIndex(0);
111-
return;
112-
}
113-
const timer = setInterval(() => {
114-
setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length);
115-
}, 80);
116-
return () => clearInterval(timer);
117-
}, [busy]);
118-
119-
const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> ";
105+
const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement {
120106
return (
121-
<Box width={2}>
122-
<Text color={busy ? "yellow" : "#229ac3"}>{prefix}</Text>
107+
<Box width={PROMPT_PREFIX_WIDTH}>
108+
<Text color="#229ac3">{"> "}</Text>
123109
</Box>
124110
);
125111
});
@@ -131,6 +117,7 @@ export const PromptInput = React.memo(function PromptInput({
131117
screenWidth,
132118
promptHistory,
133119
busy,
120+
cursorLayoutKey,
134121
loadingText,
135122
disabled,
136123
placeholder,
@@ -205,12 +192,14 @@ export const PromptInput = React.memo(function PromptInput({
205192
: hasExpandedRegions
206193
? " · ctrl+o collapse"
207194
: "";
195+
const busyStatusText =
196+
loadingText && loadingText.trim()
197+
? `${loadingText}${processOrPasteHint}`
198+
: `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`;
208199
const footerText = statusMessage
209200
? statusMessage
210201
: busy
211-
? loadingText && loadingText.trim()
212-
? `${loadingText}${processOrPasteHint}`
213-
: `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`
202+
? busyStatusText
214203
: `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`;
215204
const showFooterText = useMemo(
216205
() => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu,
@@ -225,19 +214,25 @@ export const PromptInput = React.memo(function PromptInput({
225214
const useInlineCursor = isPromptCursorAtWrapBoundary(buffer, inputContentWidth);
226215
const usePositionedCursor = !disabled && hasTerminalFocus && !showFooterText && stdout.isTTY && !useInlineCursor;
227216
const promptCursorLayoutKey = useMemo(
228-
() => [screenWidth, imageUrls.length, selectedSkills.map((skill) => skill.name).join("\u001F")].join("\u001E"),
229-
[imageUrls.length, screenWidth, selectedSkills]
217+
() =>
218+
[
219+
screenWidth,
220+
cursorLayoutKey ?? "default",
221+
imageUrls.length,
222+
selectedSkills.map((skill) => skill.name).join("\u001F"),
223+
].join("\u001E"),
224+
[cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills]
230225
);
231226
useTerminalFocusReporting(stdout, !disabled);
232227
useTerminalExtendedKeys(stdout, !disabled);
233228
useBracketedPaste(stdout, !disabled);
234229
const terminalCursorActive = usePromptTerminalCursor(
235230
inputTextRef,
236231
cursorPlacement,
237-
usePositionedCursor,
232+
!busy && usePositionedCursor,
238233
promptCursorLayoutKey
239234
);
240-
useHiddenTerminalCursor(stdout, !disabled && !terminalCursorActive);
235+
useHiddenTerminalCursor(stdout, !disabled && (busy || !terminalCursorActive));
241236

242237
const refreshFileMentionItems = React.useCallback(() => {
243238
setFileMentionItems(scanFileMentionItems(projectRoot));
@@ -779,15 +774,15 @@ export const PromptInput = React.memo(function PromptInput({
779774
borderRight={false}
780775
borderDimColor
781776
>
782-
<PromptPrefixLine busy={busy} />
777+
<PromptPrefixLine />
783778
<Box ref={inputTextRef} flexGrow={1} flexShrink={1} width={inputContentWidth}>
784779
<Text wrap="hard">
785780
{renderBufferWithCursor(
786781
buffer,
787782
!disabled && hasTerminalFocus,
788783
placeholder,
789784
pastesRef.current,
790-
!terminalCursorActive
785+
!busy && !terminalCursorActive
791786
)}
792787
</Text>
793788
{inlineHint ? <Text dimColor>{inlineHint}</Text> : null}

0 commit comments

Comments
 (0)