Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ available combinations.
| Tab | `Tab (no Shift)` |
| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Focus the Gemini input from the shell input. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` |
| Suspend the application (not yet implemented). | `Ctrl + Z` |
Expand Down
25 changes: 24 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 's', ctrl: true },
],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1940,6 +1940,38 @@ describe('AppContainer State Management', () => {
unmount();
});
});

describe('Focus Handling (Tab / Shift+Tab)', () => {
beforeEach(() => {
// Mock activePtyId to enable focus
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 1,
});
});

it('should focus shell input on Tab', async () => {
await setupKeypressTest();

pressKey({ name: 'tab', shift: false });

expect(capturedUIState.embeddedShellFocused).toBe(true);
unmount();
});

it('should unfocus shell input on Shift+Tab', async () => {
await setupKeypressTest();

// Focus first
pressKey({ name: 'tab', shift: false });
expect(capturedUIState.embeddedShellFocused).toBe(true);

// Unfocus via Shift+Tab
pressKey({ name: 'tab', shift: true });
expect(capturedUIState.embeddedShellFocused).toBe(false);
unmount();
});
});
});

describe('Copy Mode (CTRL+S)', () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1503,15 +1503,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
setEmbeddedShellFocused(false);
return true;
}

if (embeddedShellFocused) {
handleWarning('Press Shift+Tab to focus out.');
return true;
return false;
}

const now = Date.now();
Expand Down Expand Up @@ -1547,6 +1541,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
} else if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
return true;
}
return false;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export const BackgroundShellDisplay = ({
}

if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
return false;
}

if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,10 @@ 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 (
Expand All @@ -932,7 +936,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setEmbeddedShellFocused(true);
}
return true;
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Returning false here is correct for allowing the event to bubble up to AppContainer. However, the setEmbeddedShellFocused(true) call on line 937 causes a problem. When the event bubbles, AppContainer sees that embeddedShellFocused is already true and incorrectly displays a warning: "Press Shift+Tab to focus out."

The InputPrompt should not be setting this state. Please remove the if block on lines 933-938 that calls setEmbeddedShellFocused to make AppContainer the single source of truth for this logic.

References
  1. Maintain consistency with existing UI behavior across components. Defer non-standard UX pattern improvements to be addressed holistically rather than in a single component.

}

// Fall back to the text buffer's default input handling for all other keys
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/ui/components/ShellInputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('<ShellToolMessage />', () => {

// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Focused)');
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
});

// Now update status to Success
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/ui/components/messages/ShellToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { StickyHeader } from '../StickyHeader.js';
import { useUIActions } from '../../contexts/UIActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command, keyMatchers } from '../../keyMatchers.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import {
ToolStatusIndicator,
Expand Down Expand Up @@ -89,6 +91,17 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({

useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });

useKeypress(
(key) => {
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
handleFocus();
return true;
}
return false;
},
{ isActive: !!isThisShellFocusable && !isThisShellFocused },
);

const wasFocusedRef = React.useRef(false);

React.useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/messages/ToolShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const FocusHint: React.FC<{
return (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
{isThisShellFocused ? '(Shift+Tab to unfocus)' : '(tab to focus)'}
</Text>
</Box>
);
Expand Down
Loading