From 6806af7db30fb4a627be5f4204e353199f4ba078 Mon Sep 17 00:00:00 2001 From: Anurag Kumar Singh Date: Mon, 7 Sep 2026 22:50:32 +0530 Subject: [PATCH] fix(cli): prevent ghost text wrapping infinite loop at narrow widths Fixes #19985 When inputWidth is narrower than a single character's rendered width (e.g. wide CJK or emoji characters in narrow terminal widths), splitIndex remains 0 in getGhostTextLines and wordToProcess never shrinks, causing an infinite while loop that pegs CPU at 100% and freezes the prompt. Force-advance by at least one code point when splitIndex is 0 so the hard-split loop is guaranteed to terminate. Adds a regression unit test. --- .../src/ui/components/InputPrompt.test.tsx | 35 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 7 ++++ 2 files changed, 42 insertions(+) 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); }