diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9413ae79a4f..0f39bbf9dd1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -5387,6 +5387,43 @@ describe('InputPrompt', () => { }); }); + describe('ghost text wrapping', () => { + it('does not freeze when ghost text contains wide chars and inputWidth is narrow', async () => { + // Regression test for issue #19985: when inputWidth (1) is smaller than a + // wide character's rendered width (2 for emoji), the inner for-loop + // consumed nothing and splitIndex stayed 0, so cpSlice(word, 0) returned + // the full word unchanged, causing an infinite while loop. + props.inputWidth = 1; + props.suggestionsWidth = 1; + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + promptCompletion: { + text: '😀', + accept: vi.fn(), + clear: vi.fn(), + isLoading: false, + isActive: true, + markSelected: vi.fn(), + }, + }); + mockBuffer.text = ''; + mockBuffer.lines = ['']; + mockBuffer.cursor = [0, 0]; + + const { lastFrame, unmount } = await renderWithProviders( + , + { uiActions }, + ); + + // If the infinite-loop bug is present this waitFor will time out; with + // the fix the component renders immediately. + 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); }