From ba1925857bd3ecc924230a7b3afd05de452f5fd4 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 19 May 2026 10:56:24 +0800 Subject: [PATCH 1/3] fix(cli): block Windows Tab approval-mode toggle when input has a Tab consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4171. On Windows, Shift+Tab is indistinguishable from a bare Tab in many terminals, so useAutoAcceptIndicator accepts a bare Tab as the approval-mode cycle shortcut. To avoid double-firing with the input area, AppContainer passes a `shouldBlockTab` callback that suppresses the cycle when the input has its own Tab handler. Until now that callback only tracked the autocomplete dropdown (`shouldShowSuggestions`). When the buffer was empty and the followup prompt-suggestion ("input prediction") was visible, pressing Tab on Windows accepted the suggestion *and* cycled approval mode at the same time — the exact behaviour reported in #4171. The mid-input ghost-text and reverse/command-search paths had the same gap. Broaden the signal: compute `hasTabConsumer` from every Tab consumer inside InputPrompt — autocomplete dropdown, followup suggestion, mid-input ghost text, reverse-search, command-search — and feed that into `shouldBlockTab`. A single Tab keystroke now triggers exactly one action on Windows; macOS and Linux behaviour is unchanged. Tests cover the four states (followup visible, ghost text visible, autocomplete visible, idle). --- packages/cli/src/ui/AppContainer.tsx | 11 ++- .../src/ui/components/InputPrompt.test.tsx | 83 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 16 +++- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b970093f150..25d75de4063 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1219,8 +1219,11 @@ export const AppContainer = (props: AppContainerProps) => { hideTips: tipsDisabled, }); - // Track whether suggestions are visible for Tab key handling - const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false); + // Track whether the input area has any Tab consumer (autocomplete dropdown, + // followup suggestion, mid-input ghost text, reverse/command search). When + // true, we suppress the Windows-only "bare Tab cycles approval mode" + // fallback so a single Tab keystroke triggers only one action. See #4171. + const [hasTabConsumer, setHasTabConsumer] = useState(false); const agentViewState = useAgentViewState(); const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState(); @@ -1246,7 +1249,7 @@ export const AppContainer = (props: AppContainerProps) => { config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, - shouldBlockTab: () => hasSuggestionsVisible, + shouldBlockTab: () => hasTabConsumer, disabled: agentViewState.activeView !== 'main', }); @@ -3250,7 +3253,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFolderTrustSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, - onSuggestionsVisibilityChange: setHasSuggestionsVisible, + onSuggestionsVisibilityChange: setHasTabConsumer, refreshStatic, handleFinalSubmit, handleRetryLastPrompt: retryLastPrompt, diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 515ac10cebf..9ffd686f90a 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -431,6 +431,89 @@ describe('InputPrompt', () => { }); }); + // Regression for #4171: onSuggestionsVisibilityChange (consumed by + // AppContainer as `shouldBlockTab`) must report `true` whenever ANY + // input-side handler would consume Tab — not just the autocomplete + // dropdown. Otherwise on Windows the bare-Tab approval-mode fallback + // double-fires alongside the input-side handler. + describe('hasTabConsumer reporting (issue #4171)', () => { + // Match the SUGGESTION_DELAY_MS debounce inside createFollowupController. + const SUGGESTION_VISIBLE_WAIT_MS = 700; + + it('reports true while the followup prompt suggestion is visible', async () => { + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(SUGGESTION_VISIBLE_WAIT_MS); + + expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + unmount(); + }); + + it('reports true while mid-input ghost text offers an accept', async () => { + mockCommandCompletion.midInputGhostText = { + text: 'ile.txt', + insertPosition: 1, + acceptText: 'ile.txt', + }; + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + + expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + unmount(); + }); + + it('reports true while the autocomplete dropdown is visible', async () => { + mockCommandCompletion.showSuggestions = true; + mockCommandCompletion.suggestions = [ + { + value: '/clear', + label: '/clear', + description: 'Clear screen', + }, + ] as UseCommandCompletionReturn['suggestions']; + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + + expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + unmount(); + }); + + it('reports false when the input area is idle (no dropdown, no followup, no ghost)', async () => { + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + + // Pin the actual value (not just "never true") — the mount-time effect + // must report false when nothing in the input area wants Tab. + expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(false); + expect(onSuggestionsVisibilityChange).not.toHaveBeenCalledWith(true); + unmount(); + }); + }); + it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => { props.shellModeActive = true; const { stdin, unmount } = renderWithProviders(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1400b16ec8a..022941d66db 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1418,12 +1418,22 @@ export const InputPrompt: React.FC = ({ (shouldUseExportSuggestions && exportCompletion.shouldShowSuggestions) || activeCompletion.showSuggestions; - // Notify parent about suggestions visibility changes + // Whether any input-side handler would consume a Tab keystroke. The parent + // (AppContainer) feeds this into useAutoAcceptIndicator's `shouldBlockTab` + // so the Windows-only "bare Tab cycles approval mode" fallback doesn't + // double-fire alongside an input-area Tab handler. See issue #4171. + const hasTabConsumer = + shouldShowSuggestions || + (followup.state.isVisible && Boolean(followup.state.suggestion)) || + Boolean(completion.midInputGhostText?.acceptText) || + reverseSearchActive || + commandSearchActive; + useEffect(() => { if (onSuggestionsVisibilityChange) { - onSuggestionsVisibilityChange(shouldShowSuggestions); + onSuggestionsVisibilityChange(hasTabConsumer); } - }, [shouldShowSuggestions, onSuggestionsVisibilityChange]); + }, [hasTabConsumer, onSuggestionsVisibilityChange]); // Trigger prompt suggestion when prop changes useEffect(() => { From 53278d5ba3721af0301ae85cd4cbb6b4502ccd96 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 19 May 2026 14:48:10 +0800 Subject: [PATCH 2/3] fix(cli): tighten hasTabConsumer, add unmount cleanup + tests (#4308 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on PR #4308 addressed together — all touch the same `hasTabConsumer` signal surface exposed from InputPrompt to AppContainer. 1. **Tighten signal semantics (Copilot)**: drop the standalone `reverseSearchActive || commandSearchActive` terms. When those overlays have matches, their `showSuggestions` flag already flows into `shouldShowSuggestions` and Tab is consumed via `ACCEPT_SUGGESTION_REVERSE_SEARCH`. When they're active without matches, Tab is NOT consumed — including the bare flags misrepresented the signal as "Tab consumer present" when it really meant "modal overlay open". `hasTabConsumer` now strictly matches its name. 2. **useEffect cleanup on unmount (wenshao)**: previously, if any Tab consumer was active when InputPrompt unmounted (e.g. streaming begins while autocomplete is open), AppContainer's `hasTabConsumer` state retained the stale `true` value and kept blocking Windows Tab approval-mode cycling for the entire unmount window. Effect now resets to `false` on cleanup. The pre-existing code had the same gap with one trigger; expanding to 3 triggers materially raised the likelihood. 3. **JSDoc on prop name (wenshao)**: `onSuggestionsVisibilityChange` now carries broader "Tab consumer" semantics than the name suggests. Cross-file rename across UIActionsContext + Composer + AppContainer is too much churn for #4308's scope; add JSDoc on the prop declaration documenting the broader signal and that the name is retained for backward compatibility. 4. **Test coverage (wenshao)**: add two tests — autocomplete dismissal reports `false` (true→false transition); unmount-while-active reports `false` (cleanup regression guard). --- .../src/ui/components/InputPrompt.test.tsx | 69 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 29 ++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9ffd686f90a..15f30563288 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -512,6 +512,75 @@ describe('InputPrompt', () => { expect(onSuggestionsVisibilityChange).not.toHaveBeenCalledWith(true); unmount(); }); + + it('reports false again after the autocomplete dropdown is dismissed', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { + value: '/clear', + label: '/clear', + description: 'Clear screen', + }, + ] as UseCommandCompletionReturn['suggestions'], + }); + const onSuggestionsVisibilityChange = vi.fn(); + const { rerender, unmount } = renderWithProviders( + , + ); + await wait(); + expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(true); + + // Dismiss the dropdown and re-render — Windows Tab cycling must be + // re-enabled. Without this transition signal, the parent would keep + // suppressing the approval-mode fallback after the dropdown closed. + mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); + rerender( + , + ); + await wait(); + + expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(false); + unmount(); + }); + + it('reports false on unmount even if a Tab consumer was active', async () => { + // Regression for the stale-signal bug: if InputPrompt unmounts while + // some Tab consumer is true (e.g. streaming starts while autocomplete + // is open), AppContainer would otherwise keep blocking Windows Tab + // approval-mode cycling for the entire streaming window. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { + value: '/clear', + label: '/clear', + description: 'Clear screen', + }, + ] as UseCommandCompletionReturn['suggestions'], + }); + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(true); + + unmount(); + // Last call after unmount must be false — the cleanup function fires. + expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(false); + }); }); it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 022941d66db..27fc3df506b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -87,6 +87,16 @@ export interface InputPromptProps { onEscapePromptChange?: (showPrompt: boolean) => void; onToggleShortcuts?: () => void; showShortcuts?: boolean; + /** + * Reports whether any input-area Tab consumer is active — autocomplete + * dropdown, followup prompt suggestion, or mid-input ghost text. AppContainer + * feeds this into useAutoAcceptIndicator's `shouldBlockTab` to suppress the + * Windows-only "bare Tab cycles approval mode" fallback when an input-side + * handler already wants the Tab keystroke. See #4171. + * + * Name retained for backward compatibility with existing context wiring; + * the value tracks more than just suggestion-dropdown visibility. + */ onSuggestionsVisibilityChange?: (visible: boolean) => void; vimHandleInput?: (key: Key) => boolean; isEmbeddedShellFocused?: boolean; @@ -1422,17 +1432,24 @@ export const InputPrompt: React.FC = ({ // (AppContainer) feeds this into useAutoAcceptIndicator's `shouldBlockTab` // so the Windows-only "bare Tab cycles approval mode" fallback doesn't // double-fire alongside an input-area Tab handler. See issue #4171. + // + // Note on reverse/command-search: when those overlays have matches, their + // `showSuggestions` flag flows into `shouldShowSuggestions` above and Tab IS + // consumed (ACCEPT_SUGGESTION_REVERSE_SEARCH). When they are active with no + // matches, Tab is not consumed — so the bare `reverseSearchActive` / + // `commandSearchActive` flags are intentionally NOT included here. const hasTabConsumer = shouldShowSuggestions || (followup.state.isVisible && Boolean(followup.state.suggestion)) || - Boolean(completion.midInputGhostText?.acceptText) || - reverseSearchActive || - commandSearchActive; + Boolean(completion.midInputGhostText?.acceptText); useEffect(() => { - if (onSuggestionsVisibilityChange) { - onSuggestionsVisibilityChange(hasTabConsumer); - } + onSuggestionsVisibilityChange?.(hasTabConsumer); + // Reset to false on unmount (e.g. when InputPrompt unmounts during + // streaming) so AppContainer's stale `hasTabConsumer` doesn't keep + // blocking Windows Tab approval-mode cycling while there is no input + // area to consume the keystroke. + return () => onSuggestionsVisibilityChange?.(false); }, [hasTabConsumer, onSuggestionsVisibilityChange]); // Trigger prompt suggestion when prop changes From f66392b4f4f696161d69a2154941babd8935e079 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 19 May 2026 15:47:05 +0800 Subject: [PATCH 3/3] fix(cli): split Tab-consumer signal so it doesn't hide Footer (#4308 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-inflicted regression caught by wenshao: the previous round broadened `onSuggestionsVisibilityChange` from "autocomplete dropdown visible" to "any Tab consumer present", but Composer.tsx was using that same callback for a different purpose — hiding the Footer / KeyboardShortcuts when the dropdown would overlap their vertical space. As a result, followup prompt suggestions and mid-input ghost text (both inline within the input box, neither competing for vertical space) were also hiding the Footer on every platform. Split into two signals: - `onSuggestionsVisibilityChange` — narrow, autocomplete dropdown only. Kept local to Composer for Footer hiding. Restored to pre-PR semantics; no cleanup-on-unmount needed (the entire conditional in Composer.tsx is already gated by `uiState.isInputActive`, which goes false when InputPrompt unmounts). - `onTabConsumerChange` — broad, any input-side Tab consumer (autocomplete + followup + ghost text). Plumbed through UIActionsContext to AppContainer's `hasTabConsumer` state → useAutoAcceptIndicator's `shouldBlockTab`. Retains the cleanup-on-unmount wenshao added last round (the broad signal IS read while InputPrompt is unmounted). Tests: - All 6 broad-signal regression tests renamed to assert `onTabConsumerChange`. - 3 new narrow-signal regression tests pin that `onSuggestionsVisibilityChange` does NOT fire `true` for followup or ghost text. Catches the exact shape of my regression. --- packages/cli/src/ui/AppContainer.tsx | 2 +- packages/cli/src/ui/components/Composer.tsx | 20 ++- .../src/ui/components/InputPrompt.test.tsx | 140 ++++++++++++------ .../cli/src/ui/components/InputPrompt.tsx | 52 ++++--- .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- 5 files changed, 146 insertions(+), 70 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 25d75de4063..9f283060b49 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3253,7 +3253,7 @@ export const AppContainer = (props: AppContainerProps) => { handleFolderTrustSelect, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, - onSuggestionsVisibilityChange: setHasTabConsumer, + onTabConsumerChange: setHasTabConsumer, refreshStatic, handleFinalSubmit, handleRetryLastPrompt: retryLastPrompt, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 846244b85d7..5e55f2efcbc 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -75,13 +75,18 @@ export const Composer = () => { setShowShortcuts((prev) => !prev); }, []); - // State for suggestions visibility + // State for autocomplete-dropdown visibility (narrow signal). Drives the + // Footer / KeyboardShortcuts hide-when-dropdown-visible logic below; kept + // local to Composer because nothing outside this component needs the + // narrow signal. const [showSuggestions, setShowSuggestions] = useState(false); - const handleSuggestionsVisibilityChange = useCallback( - (visible: boolean) => { - setShowSuggestions(visible); - // Also notify AppContainer for Tab key handling - uiActions.onSuggestionsVisibilityChange(visible); + + // Broad signal — any input-area Tab consumer. Forwarded to AppContainer + // via UIActionsContext so useAutoAcceptIndicator's `shouldBlockTab` can + // suppress the Windows-only bare-Tab approval-mode fallback. See #4171. + const handleTabConsumerChange = useCallback( + (active: boolean) => { + uiActions.onTabConsumerChange(active); }, [uiActions], ); @@ -145,7 +150,8 @@ export const Composer = () => { onEscapePromptChange={uiActions.onEscapePromptChange} onToggleShortcuts={handleToggleShortcuts} showShortcuts={showShortcuts} - onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange} + onSuggestionsVisibilityChange={setShowSuggestions} + onTabConsumerChange={handleTabConsumerChange} focus={true} vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 15f30563288..d62ff88cbd3 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -431,27 +431,27 @@ describe('InputPrompt', () => { }); }); - // Regression for #4171: onSuggestionsVisibilityChange (consumed by - // AppContainer as `shouldBlockTab`) must report `true` whenever ANY - // input-side handler would consume Tab — not just the autocomplete - // dropdown. Otherwise on Windows the bare-Tab approval-mode fallback - // double-fires alongside the input-side handler. - describe('hasTabConsumer reporting (issue #4171)', () => { + // Regression for #4171: `onTabConsumerChange` (consumed by AppContainer + // as `shouldBlockTab`) must report `true` whenever ANY input-side handler + // would consume Tab — autocomplete dropdown, followup suggestion, or + // mid-input ghost text. Otherwise on Windows the bare-Tab approval-mode + // fallback double-fires alongside the input-side handler. + describe('onTabConsumerChange reporting (issue #4171)', () => { // Match the SUGGESTION_DELAY_MS debounce inside createFollowupController. const SUGGESTION_VISIBLE_WAIT_MS = 700; it('reports true while the followup prompt suggestion is visible', async () => { - const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { unmount } = renderWithProviders( , ); await wait(SUGGESTION_VISIBLE_WAIT_MS); - expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + expect(onTabConsumerChange).toHaveBeenCalledWith(true); unmount(); }); @@ -461,16 +461,13 @@ describe('InputPrompt', () => { insertPosition: 1, acceptText: 'ile.txt', }; - const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { unmount } = renderWithProviders( - , + , ); await wait(); - expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + expect(onTabConsumerChange).toHaveBeenCalledWith(true); unmount(); }); @@ -483,33 +480,27 @@ describe('InputPrompt', () => { description: 'Clear screen', }, ] as UseCommandCompletionReturn['suggestions']; - const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { unmount } = renderWithProviders( - , + , ); await wait(); - expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); + expect(onTabConsumerChange).toHaveBeenCalledWith(true); unmount(); }); it('reports false when the input area is idle (no dropdown, no followup, no ghost)', async () => { - const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { unmount } = renderWithProviders( - , + , ); await wait(); // Pin the actual value (not just "never true") — the mount-time effect // must report false when nothing in the input area wants Tab. - expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(false); - expect(onSuggestionsVisibilityChange).not.toHaveBeenCalledWith(true); + expect(onTabConsumerChange).toHaveBeenCalledWith(false); + expect(onTabConsumerChange).not.toHaveBeenCalledWith(true); unmount(); }); @@ -525,29 +516,23 @@ describe('InputPrompt', () => { }, ] as UseCommandCompletionReturn['suggestions'], }); - const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { rerender, unmount } = renderWithProviders( - , + , ); await wait(); - expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(true); + expect(onTabConsumerChange).toHaveBeenLastCalledWith(true); // Dismiss the dropdown and re-render — Windows Tab cycling must be // re-enabled. Without this transition signal, the parent would keep // suppressing the approval-mode fallback after the dropdown closed. mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); rerender( - , + , ); await wait(); - expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(false); + expect(onTabConsumerChange).toHaveBeenLastCalledWith(false); unmount(); }); @@ -567,19 +552,90 @@ describe('InputPrompt', () => { }, ] as UseCommandCompletionReturn['suggestions'], }); + const onTabConsumerChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + expect(onTabConsumerChange).toHaveBeenLastCalledWith(true); + + unmount(); + // Last call after unmount must be false — the cleanup function fires. + expect(onTabConsumerChange).toHaveBeenLastCalledWith(false); + }); + }); + + // Regression for #4308 review: `onSuggestionsVisibilityChange` must stay + // narrow (autocomplete dropdown only). Composer uses this signal to hide + // the Footer / KeyboardShortcuts when the dropdown competes for vertical + // space. Followup suggestions and mid-input ghost text are inline within + // the input box and must NOT hide the Footer — broadening this signal + // would cause Footer churn on all platforms. + describe('onSuggestionsVisibilityChange stays narrow (autocomplete only)', () => { + const SUGGESTION_VISIBLE_WAIT_MS = 700; + + it('stays false when only a followup prompt suggestion is visible', async () => { const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); const { unmount } = renderWithProviders( , + ); + await wait(SUGGESTION_VISIBLE_WAIT_MS); + + // Tab consumer signal flips true (followup is a Tab consumer)… + expect(onTabConsumerChange).toHaveBeenCalledWith(true); + // …but the narrow signal must NOT — Footer should stay visible. + expect(onSuggestionsVisibilityChange).not.toHaveBeenCalledWith(true); + unmount(); + }); + + it('stays false when only mid-input ghost text is present', async () => { + mockCommandCompletion.midInputGhostText = { + text: 'ile.txt', + insertPosition: 1, + acceptText: 'ile.txt', + }; + const onSuggestionsVisibilityChange = vi.fn(); + const onTabConsumerChange = vi.fn(); + const { unmount } = renderWithProviders( + , ); await wait(); - expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(true); + expect(onTabConsumerChange).toHaveBeenCalledWith(true); + expect(onSuggestionsVisibilityChange).not.toHaveBeenCalledWith(true); + unmount(); + }); + + it('flips true only when the autocomplete dropdown is visible', async () => { + mockCommandCompletion.showSuggestions = true; + mockCommandCompletion.suggestions = [ + { + value: '/clear', + label: '/clear', + description: 'Clear screen', + }, + ] as UseCommandCompletionReturn['suggestions']; + const onSuggestionsVisibilityChange = vi.fn(); + const { unmount } = renderWithProviders( + , + ); + await wait(); + + expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true); unmount(); - // Last call after unmount must be false — the cleanup function fires. - expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(false); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 27fc3df506b..259f0de5e28 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -88,16 +88,21 @@ export interface InputPromptProps { onToggleShortcuts?: () => void; showShortcuts?: boolean; /** - * Reports whether any input-area Tab consumer is active — autocomplete - * dropdown, followup prompt suggestion, or mid-input ghost text. AppContainer - * feeds this into useAutoAcceptIndicator's `shouldBlockTab` to suppress the - * Windows-only "bare Tab cycles approval mode" fallback when an input-side - * handler already wants the Tab keystroke. See #4171. - * - * Name retained for backward compatibility with existing context wiring; - * the value tracks more than just suggestion-dropdown visibility. + * Reports autocomplete-dropdown visibility specifically. Composer uses + * this to hide the Footer / KeyboardShortcuts when the dropdown would + * overlap their vertical space. Must stay narrow — followup suggestions + * and mid-input ghost text don't take Footer's space and shouldn't hide + * it. See #4171 / #4308 review. */ onSuggestionsVisibilityChange?: (visible: boolean) => void; + /** + * Reports whether any input-area handler will consume a Tab keystroke + * (autocomplete dropdown, followup prompt suggestion, or mid-input ghost + * text). AppContainer feeds this into useAutoAcceptIndicator's + * `shouldBlockTab` to suppress the Windows-only "bare Tab cycles approval + * mode" fallback. See #4171. + */ + onTabConsumerChange?: (active: boolean) => void; vimHandleInput?: (key: Key) => boolean; isEmbeddedShellFocused?: boolean; /** Prompt suggestion text to display after response completes */ @@ -132,6 +137,7 @@ export const InputPrompt: React.FC = ({ onToggleShortcuts, showShortcuts, onSuggestionsVisibilityChange, + onTabConsumerChange, vimHandleInput, isEmbeddedShellFocused, promptSuggestion, @@ -1428,10 +1434,10 @@ export const InputPrompt: React.FC = ({ (shouldUseExportSuggestions && exportCompletion.shouldShowSuggestions) || activeCompletion.showSuggestions; - // Whether any input-side handler would consume a Tab keystroke. The parent - // (AppContainer) feeds this into useAutoAcceptIndicator's `shouldBlockTab` - // so the Windows-only "bare Tab cycles approval mode" fallback doesn't - // double-fire alongside an input-area Tab handler. See issue #4171. + // Whether any input-side handler would consume a Tab keystroke. AppContainer + // feeds this into useAutoAcceptIndicator's `shouldBlockTab` so the + // Windows-only "bare Tab cycles approval mode" fallback doesn't double-fire + // alongside an input-area Tab handler. See issue #4171. // // Note on reverse/command-search: when those overlays have matches, their // `showSuggestions` flag flows into `shouldShowSuggestions` above and Tab IS @@ -1443,14 +1449,22 @@ export const InputPrompt: React.FC = ({ (followup.state.isVisible && Boolean(followup.state.suggestion)) || Boolean(completion.midInputGhostText?.acceptText); + // Narrow signal — autocomplete dropdown only. Composer hides Footer / + // KeyboardShortcuts when this is true because the dropdown competes for + // the same vertical space. Followup / ghost text are inline within the + // input box and must NOT hide the Footer (#4308 review). + useEffect(() => { + onSuggestionsVisibilityChange?.(shouldShowSuggestions); + }, [shouldShowSuggestions, onSuggestionsVisibilityChange]); + + // Broad signal — any Tab consumer. Reset to false on unmount (e.g. when + // InputPrompt unmounts during streaming) so AppContainer's stale + // `hasTabConsumer` doesn't keep blocking Windows Tab approval-mode cycling + // while there is no input area to consume the keystroke. useEffect(() => { - onSuggestionsVisibilityChange?.(hasTabConsumer); - // Reset to false on unmount (e.g. when InputPrompt unmounts during - // streaming) so AppContainer's stale `hasTabConsumer` doesn't keep - // blocking Windows Tab approval-mode cycling while there is no input - // area to consume the keystroke. - return () => onSuggestionsVisibilityChange?.(false); - }, [hasTabConsumer, onSuggestionsVisibilityChange]); + onTabConsumerChange?.(hasTabConsumer); + return () => onTabConsumerChange?.(false); + }, [hasTabConsumer, onTabConsumerChange]); // Trigger prompt suggestion when prop changes useEffect(() => { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7ce19a9f7bb..212ab066ac9 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -59,7 +59,7 @@ export interface UIActions { handleFolderTrustSelect: (choice: FolderTrustChoice) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; - onSuggestionsVisibilityChange: (visible: boolean) => void; + onTabConsumerChange: (active: boolean) => void; refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleRetryLastPrompt: () => void;