Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1246,7 +1249,7 @@ export const AppContainer = (props: AppContainerProps) => {
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
shouldBlockTab: () => hasSuggestionsVisible,
shouldBlockTab: () => hasTabConsumer,
disabled: agentViewState.activeView !== 'main',
});

Expand Down Expand Up @@ -3250,7 +3253,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleFolderTrustSelect,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
onSuggestionsVisibilityChange: setHasSuggestionsVisible,
onSuggestionsVisibilityChange: setHasTabConsumer,
refreshStatic,
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
Expand Down
152 changes: 152 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,158 @@ 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(
<InputPrompt
{...props}
promptSuggestion="commit this"
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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('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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
await wait();
expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(true);

unmount();
// Last call after unmount must be false — the cleanup function fires.
expect(onSuggestionsVisibilityChange).toHaveBeenLastCalledWith(false);
});
});

Comment thread
LaZzyMan marked this conversation as resolved.
Resolved
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
Expand Down
37 changes: 32 additions & 5 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1418,12 +1428,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(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.
//
// 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);

useEffect(() => {
if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions);
}
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
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]);
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved

// Trigger prompt suggestion when prop changes
useEffect(() => {
Expand Down
Loading