diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index a2b0788f939..7bfbdbfd87f 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -5394,6 +5394,41 @@ describe('InputPrompt', () => {
});
});
+ describe('ghost text wrapping', () => {
+ it('does not freeze when ghost text contains wide chars and inputWidth is narrow', async () => {
+ // Regression for #19985: when inputWidth is smaller than a wide
+ // character's rendered width, the hard-split loop must still advance.
+ // buffer.text must be non-empty so getGhostTextLines does not early-return.
+ props.inputWidth = 1;
+ props.suggestionsWidth = 1;
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ promptCompletion: {
+ text: 'a' + '好'.repeat(10),
+ accept: vi.fn(),
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: true,
+ markSelected: vi.fn(),
+ },
+ });
+ mockBuffer.text = 'a';
+ mockBuffer.lines = ['a'];
+ mockBuffer.cursor = [0, 1];
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { uiState: {} },
+ );
+
+ // Without the guard this hang pegs CPU and the wait times out.
+ await waitFor(() => {
+ expect(lastFrame()).toBeDefined();
+ });
+ unmount();
+ });
+ });
+
describe('terminal buffer rendering', () => {
it('does not clip the last char of a visual line whose width equals inputWidth', async () => {
const fullLine = '1234567890'; // 10 chars, exactly props.inputWidth
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 511c4b6ceb6..d7e7eb80b62 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -1515,6 +1515,13 @@ export const InputPrompt: React.FC = ({
partWidth += charWidth;
splitIndex = i + 1;
}
+ // When inputWidth is narrower than a single codepoint, nothing
+ // fits and splitIndex stays 0, causing an infinite loop. Force-
+ // advance by one codepoint so the loop always terminates.
+ if (splitIndex === 0 && wordCP.length > 0) {
+ part = wordCP[0];
+ splitIndex = 1;
+ }
additionalLines.push(part);
wordToProcess = cpSlice(wordToProcess, splitIndex);
}