Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,
onTabConsumerChange: setHasTabConsumer,
refreshStatic,
handleFinalSubmit,
handleRetryLastPrompt: retryLastPrompt,
Expand Down
20 changes: 13 additions & 7 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down Expand Up @@ -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}
Expand Down
208 changes: 208 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,214 @@ describe('InputPrompt', () => {
});
});

// 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 onTabConsumerChange = vi.fn();
const { unmount } = renderWithProviders(
<InputPrompt
{...props}
promptSuggestion="commit this"
onTabConsumerChange={onTabConsumerChange}
/>,
);
await wait(SUGGESTION_VISIBLE_WAIT_MS);

expect(onTabConsumerChange).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 onTabConsumerChange = vi.fn();
const { unmount } = renderWithProviders(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
await wait();

expect(onTabConsumerChange).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 onTabConsumerChange = vi.fn();
const { unmount } = renderWithProviders(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
await wait();

expect(onTabConsumerChange).toHaveBeenCalledWith(true);
unmount();
});

it('reports false when the input area is idle (no dropdown, no followup, no ghost)', async () => {
const onTabConsumerChange = vi.fn();
const { unmount } = renderWithProviders(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
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(onTabConsumerChange).toHaveBeenCalledWith(false);
expect(onTabConsumerChange).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 onTabConsumerChange = vi.fn();
const { rerender, unmount } = renderWithProviders(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
await wait();
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(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
await wait();

expect(onTabConsumerChange).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 onTabConsumerChange = vi.fn();
const { unmount } = renderWithProviders(
<InputPrompt {...props} onTabConsumerChange={onTabConsumerChange} />,
);
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(
<InputPrompt
{...props}
promptSuggestion="commit this"
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
onTabConsumerChange={onTabConsumerChange}
/>,
);
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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
onTabConsumerChange={onTabConsumerChange}
/>,
);
await wait();

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(
<InputPrompt
{...props}
onSuggestionsVisibilityChange={onSuggestionsVisibilityChange}
/>,
);
await wait();

expect(onSuggestionsVisibilityChange).toHaveBeenCalledWith(true);
unmount();
});
});

Comment thread
LaZzyMan marked this conversation as resolved.
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
Expand Down
49 changes: 45 additions & 4 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,22 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
onToggleShortcuts?: () => void;
showShortcuts?: boolean;
/**
* 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 */
Expand Down Expand Up @@ -122,6 +137,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
onSuggestionsVisibilityChange,
onTabConsumerChange,
vimHandleInput,
isEmbeddedShellFocused,
promptSuggestion,
Expand Down Expand Up @@ -1418,13 +1434,38 @@ 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. 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);

// 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(() => {
if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions);
}
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(() => {
onTabConsumerChange?.(hasTabConsumer);
return () => onTabConsumerChange?.(false);
}, [hasTabConsumer, onTabConsumerChange]);

// Trigger prompt suggestion when prop changes
useEffect(() => {
followup.setSuggestion(promptSuggestion ?? null);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading