fix(cli): improve focus navigation for interactive and background shells (#18343)

This commit is contained in:
Gal Zahavi
2026-02-06 10:36:14 -08:00
committed by GitHub
parent f062f56b43
commit ec5836c4d6
19 changed files with 456 additions and 256 deletions
@@ -405,55 +405,4 @@ describe('<BackgroundShellDisplay />', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab', shift: true });
});
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('shows a warning when Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { commandDescriptions } from '../../config/keyBindings.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import {
ScrollableList,
type ScrollableListRef,
@@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({
(key) => {
if (!activeShell) return;
// Handle Shift+Tab or Tab (in list) to focus out
if (
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
(isListOpenProp &&
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
) {
setEmbeddedShellFocused(false);
return true;
}
// Handle Tab to warn but propagate
if (
!isListOpenProp &&
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
) {
handleWarning(
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
);
// Fall through to allow Tab to be sent to the shell
}
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
@@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
return false;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
@@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({
{ isActive: isFocused && !!activeShell },
);
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
const helpTextParts = [
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
];
const helpTextStr = helpTextParts
.map((p) => `${p.label} (${formatCommand(p.command)})`)
.join(' | ');
const renderHelpText = () => (
<Text>
{helpTextParts.map((p, i) => (
<Text key={p.label}>
{i > 0 ? ' | ' : ''}
{p.label} (
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
</Text>
))}
</Text>
);
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
@@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
getCachedStringWidth(helpTextStr) -
pidInfoWidth;
let currentWidth = 0;
@@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
@@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
{`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
{renderHelpText()}
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
@@ -982,15 +982,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
}
return true;
return false;
}
// Fall back to the text buffer's default input handling for all other keys
@@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
// Mock useUIActions
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(),
}));
// Mock useKeypress
const mockUseKeypress = vi.fn();
@@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => {
describe('ShellInputPrompt', () => {
const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
const mockHandleWarning = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useUIActions).mockReturnValue({
handleWarning: mockHandleWarning,
} as Partial<UIActions> as UIActions);
});
it('renders nothing', () => {
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
expect(lastFrame()).toBe('');
});
it('sends tab to pty', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
handler({
name: 'tab',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '\t',
});
expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
});
it.each([
['a', 'a'],
['b', 'b'],
@@ -40,6 +40,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false;
}
// Allow unfocus to bubble up
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
@@ -2,16 +2,16 @@
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -21,23 +21,23 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
@@ -46,9 +46,9 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L)
│ │
│ Select Process (Enter to select, Esc to cancel):
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel):
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { act } from 'react';
import React from 'react';
import {
ShellToolMessage,
type ShellToolMessageProps,
@@ -77,16 +77,6 @@ describe('<ShellToolMessage />', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
) =>
renderWithProviders(ui, {
uiActions,
uiState: { streamingState },
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -140,40 +130,5 @@ describe('<ShellToolMessage />', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: ToolCallStatus) => void = () => {};
const Wrapper = () => {
const [status, setStatus] = React.useState(ToolCallStatus.Executing);
updateStatus = setStatus;
return (
<ShellToolMessage
{...shellProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithContext(<Wrapper />, StreamingState.Idle);
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Focused)');
});
// Now update status to Success
await act(async () => {
updateStatus(ToolCallStatus.Success);
});
// Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
});
});
});
@@ -89,20 +89,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const wasFocusedRef = React.useRef(false);
React.useEffect(() => {
if (isThisShellFocused) {
wasFocusedRef.current = true;
} else if (wasFocusedRef.current) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
}
wasFocusedRef.current = false;
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
@@ -77,7 +77,7 @@ describe('Focus Hint', () => {
// Now it SHOULD contain the focus hint
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
it('shows focus hint after delay with output', async () => {
@@ -95,7 +95,7 @@ describe('Focus Hint', () => {
});
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
});
});
@@ -116,7 +116,7 @@ describe('Focus Hint', () => {
// The focus hint should be visible
expect(lastFrame()).toMatchSnapshot('long-description');
expect(lastFrame()).toContain('(tab to focus)');
expect(lastFrame()).toContain('(Tab to focus)');
// The name should still be visible
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
});
@@ -22,6 +22,8 @@ import {
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { Command } from '../../../config/keyBindings.js';
export const STATUS_INDICATOR_WIDTH = 3;
@@ -117,7 +119,9 @@ export const FocusHint: React.FC<{
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
</Text>
</Box>
);
@@ -2,7 +2,7 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -14,7 +14,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even wit
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -26,7 +26,7 @@ exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with out
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -38,7 +38,7 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command A tool for testing (tab to focus) │
│ Shell Command A tool for testing (Tab to focus) │
│ │"
`;
@@ -50,6 +50,6 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │
│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │"
`;