From 55a7a22471be35ad59d8e69413951ef64367352c Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Fri, 27 Mar 2026 23:41:11 +0000 Subject: [PATCH] feat(ui): automatically dismiss ephemeral /btw display on input typing By intercepting text input when the `/btw` query results are visible (but not actively streaming), we can dismiss the ephemeral BtwDisplay before the new text wraps to the next line. This fixes the UI jumpiness and "ghost space" scrolling that occurs when the terminal recalculates the tall rendered height of the previous query dynamically. Also, includes test updates to mock the spinner to eliminate `act(...)` asynchronous test warnings. --- .../cli/src/ui/components/BtwDisplay.test.tsx | 6 ++ .../src/ui/components/InputPrompt.test.tsx | 58 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 22 +++++++ 3 files changed, 86 insertions(+) diff --git a/packages/cli/src/ui/components/BtwDisplay.test.tsx b/packages/cli/src/ui/components/BtwDisplay.test.tsx index 88fe96db74..4ae5719bfe 100644 --- a/packages/cli/src/ui/components/BtwDisplay.test.tsx +++ b/packages/cli/src/ui/components/BtwDisplay.test.tsx @@ -10,6 +10,12 @@ import { BtwDisplay } from './BtwDisplay.js'; import { StreamingState } from '../types.js'; import type { UIState } from '../contexts/UIStateContext.js'; +import { Text } from 'ink'; + +vi.mock('./GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: () => , +})); + describe('BtwDisplay', () => { const defaultMockUiState = { renderMarkdown: true, diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index b274d0decf..4ca133af30 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -5182,6 +5182,64 @@ describe('InputPrompt', () => { }); unmount(); }); + + it('dismisses Btw and accepts input on typing when not streaming', async () => { + const dismissBtw = vi.fn(); + const { stdin, stdout, unmount } = await renderWithProviders( + , + { + uiState: { + btwState: { + isActive: true, + query: '', + response: '', + isStreaming: false, + error: null, + }, + }, + uiActions: { dismissBtw }, + }, + ); + + await act(async () => { + stdin.write('a'); + }); + + await waitFor(() => { + expect(dismissBtw).toHaveBeenCalled(); + expect(clean(stdout.lastFrameRaw())).toContain('a'); + }); + unmount(); + }); + + it('blocks typing when Btw is streaming', async () => { + const dismissBtw = vi.fn(); + const { stdin, stdout, unmount } = await renderWithProviders( + , + { + uiState: { + btwState: { + isActive: true, + query: '', + response: '', + isStreaming: true, + error: null, + }, + }, + uiActions: { dismissBtw }, + }, + ); + + await act(async () => { + stdin.write('Z'); + }); + + await waitFor(() => { + expect(dismissBtw).not.toHaveBeenCalled(); + expect(clean(stdout.lastFrameRaw())).not.toContain('Z'); + }); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 64dba15561..1ff100d0a3 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -694,6 +694,27 @@ export const InputPrompt: React.FC = ({ dismissBtw(); return true; } + + const isPrintable = + key.sequence && + !key.ctrl && + !key.cmd && + !key.alt && + key.sequence.length === 1; + + const isEditingKey = + isPrintable || + key.name === 'backspace' || + key.name === 'delete' || + key.name === 'paste'; + + if (isEditingKey) { + if (btwState.isStreaming) { + return true; + } else { + dismissBtw(); + } + } } // Reset completion suppression if the user performs any action other than @@ -1386,6 +1407,7 @@ export const InputPrompt: React.FC = ({ isHelpDismissKey, settings, btwState.isActive, + btwState.isStreaming, dismissBtw, ], );