Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
f9573d6
feat(cli): truncate shell command output in history
jwhelangoog Jan 23, 2026
bb4df34
feat(cli): refine shell output with scrollable viewport and optimized…
jwhelangoog Jan 24, 2026
9ebd7d0
test(cli): add comprehensive test coverage for output truncation and …
jwhelangoog Jan 24, 2026
298f990
fix(cli): optimize pruneShellOutput performance and trailing newline …
jwhelangoog Jan 24, 2026
fcbe4ee
test(cli): fix type errors in toolMapping and textUtils mocks
jwhelangoog Jan 24, 2026
7392cf0
Merge branch 'main' into feature/ux-dev/shell-output-truncation-from-…
jwhelangoog Jan 28, 2026
67ab5e0
Merge branch 'main' into feature/ux-dev/shell-output-truncation-from-…
jwhelangoog Jan 28, 2026
c4988a2
Merge branch 'upstream/main' into feature/ux-dev/shell-output-truncat…
jwhelangoog Feb 2, 2026
fabd425
feat(cli): increase shell history output to 25 lines
jwhelangoog Feb 3, 2026
b4df51d
refactor(cli): persist full shell output in history
jwhelangoog Feb 3, 2026
ef0f934
feat(cli): implement scrollable shell panes in alternate buffer
jwhelangoog Feb 3, 2026
a91ca89
fix(cli): enable Shift+Tab focus navigation from shell
jwhelangoog Feb 3, 2026
207cc12
feat(cli): centralize shell input keybindings configuration
jwhelangoog Feb 3, 2026
dc2add4
refactor(cli): implement centralized keybindings in ShellInputPrompt
jwhelangoog Feb 3, 2026
aa6a7bf
test(cli): improve coverage for toolMapping
jwhelangoog Feb 3, 2026
46c72bc
Merge remote-tracking branch 'upstream/main' into feature/ux-dev/shel…
jwhelangoog Feb 3, 2026
298076a
fix(cli): restore ctrl+o expansion for shell output
jwhelangoog Feb 3, 2026
7d99570
test(cli): update snapshots for tool group and sticky headers
jwhelangoog Feb 3, 2026
1dcb695
fix(cli): prevent extra blank lines in tool results by using maxHeight
jwhelangoog Feb 3, 2026
9e8581a
Merge remote-tracking branch 'upstream/main' into feature/ux-dev/shel…
jwhelangoog Feb 3, 2026
eaa1424
perf(cli): virtualize shell output in alternate buffer mode using Scr…
jwhelangoog Feb 3, 2026
14fb4a2
ui(cli): use overflow:hidden instead of ellipses for shell output tru…
jwhelangoog Feb 3, 2026
26f6131
refactor(cli): dynamically propagate focus to scrollable tool result …
jwhelangoog Feb 4, 2026
649025d
fix(cli): prevent shell output rendering loops and simplify ToolResul…
jwhelangoog Feb 4, 2026
58f0128
test(cli): use real Scrollable/AnsiOutput in ToolResultDisplay tests
jwhelangoog Feb 4, 2026
c36eff1
perf(cli): optimize ToolResultDisplay virtualization callbacks
jwhelangoog Feb 4, 2026
a83ac7a
fix(cli): use separate constants for active shell and history truncation
jwhelangoog Feb 4, 2026
6c70e39
Merge remote-tracking branch 'upstream/main' into feature/ux-dev/shel…
jwhelangoog Feb 4, 2026
0f508c3
feat(cli): constrain completed shell output to 25 lines in history
jwhelangoog Feb 5, 2026
2ba4dd9
fix(cli): when applicable, ensure tool result output can be scrolled …
jwhelangoog Feb 5, 2026
89c060c
style(cli): add horizontal margins to tool message boxes for improved…
jwhelangoog Feb 6, 2026
e356608
feat(cli): reduce shell output height and refine truncation
jwhelangoog Feb 6, 2026
ae8b93e
chore(test): clean up test infrastructure and remove excessive mocks
jwhelangoog Feb 6, 2026
77277f7
fix(cli): prevent shell output overflow by constraining height to ava…
jwhelangoog Feb 6, 2026
405b4f0
refactor(cli): enhance scrollable components and focus scoping
jwhelangoog Feb 6, 2026
c9fdaf8
refactor(cli): unify scrolling keybindings and sync documentation
jwhelangoog Feb 6, 2026
f662c7a
style(cli): adjust tool output margin to right-side only
jwhelangoog Feb 6, 2026
3d06430
refactor(cli): rename SHELL_LEAVE_FOCUS to UNFOCUS_SHELL
jwhelangoog Feb 7, 2026
57ce58c
refactor(cli): address PR feedback
jwhelangoog Feb 7, 2026
320c8fb
test(cli): simplify ShellToolMessage tests using renderShell helper a…
jwhelangoog Feb 7, 2026
6f15db5
Enable shell tool output expansion in Alternate Buffer mode
jwhelangoog Feb 8, 2026
d87fd62
Propagate available height to history items in Alternate Buffer mode
jwhelangoog Feb 8, 2026
70ae6fa
refactor(cli): handle scroll keypress boundaries in Scrollable
jwhelangoog Feb 8, 2026
8c62743
refactor(cli): unify shell unfocus commands into UNFOCUS_SHELL_INPUT
jwhelangoog Feb 8, 2026
5553e86
Merge remote-tracking branch 'upstream/main' into feature/ux-dev/shel…
jwhelangoog Feb 8, 2026
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
1 change: 1 addition & 0 deletions docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ available combinations.
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
| Move to the next item or question in a dialog. | `Tab (no Shift)` |
| Move to the previous item or question in a dialog. | `Shift + Tab` |
| Move focus out of the embedded shell. | `Shift + Tab` |

#### Suggestions & Completions

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export enum Command {
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',

UNFOCUS_SHELL = 'shell.unfocus',
}

/**
Expand Down Expand Up @@ -292,6 +294,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],

// Shell Controls
[Command.UNFOCUS_SHELL]: [{ key: 'tab', shift: true }],
};

interface CommandCategory {
Expand Down Expand Up @@ -365,6 +370,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.DIALOG_NAVIGATION_DOWN,
Command.DIALOG_NEXT,
Command.DIALOG_PREV,
Command.UNFOCUS_SHELL,
],
},
{
Expand Down Expand Up @@ -509,4 +515,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',

// Shell Controls
[Command.UNFOCUS_SHELL]: 'Move focus out of the embedded shell.',
};
5 changes: 3 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1500,10 +1500,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) &&
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
) {
if (key.name === 'tab' && key.shift) {
if (keyMatchers[Command.UNFOCUS_SHELL](key)) {
// Always change focus
setEmbeddedShellFocused(false);
return true;
Expand Down
44 changes: 42 additions & 2 deletions packages/cli/src/ui/components/AnsiOutput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ describe('<AnsiOutputText />', () => {
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
expect(lines[0]).toBe('First line');
expect(lines[1]).toBe('Third line');
expect(lines[0].trim()).toBe('First line');
expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
});

it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
Expand All @@ -89,6 +90,45 @@ describe('<AnsiOutputText />', () => {
expect(output).toContain('Line 4');
});

it('respects the maxLines prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});

it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
maxLines={2}
width={80}
/>,
);
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});

it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {
Expand Down
62 changes: 39 additions & 23 deletions packages/cli/src/ui/components/AnsiOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,56 @@ interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
width: number;
maxLines?: number;
disableTruncation?: boolean;
}

export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
width,
maxLines,
disableTruncation,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
const availableHeightLimit =
availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
: undefined;

const numLinesRetained =
availableHeightLimit !== undefined && maxLines !== undefined
? Math.min(availableHeightLimit, maxLines)
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);

const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
return (
<Box flexDirection="column" width={width} flexShrink={0}>
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
<Box key={lineIndex} height={1} overflow="hidden">
<AnsiLineText line={line} />
</Box>
))}
</Box>
);
};

export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export const MainContent = () => {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/components/Notifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { render, persistentStateMock } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { Notifications } from './Notifications.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAppContext, type AppState } from '../contexts/AppContext.js';
Expand Down Expand Up @@ -172,7 +173,7 @@ describe('Notifications', () => {
render(<Notifications />);

await act(async () => {
await vi.waitFor(() => {
await waitFor(() => {
expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge',
true,
Expand Down
69 changes: 67 additions & 2 deletions packages/cli/src/ui/components/ShellInputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,64 @@ describe('ShellInputPrompt', () => {
it.each([
['up', -1],
['down', 1],
])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => {
])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);

const handler = mockUseKeypress.mock.calls[0][0];

handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false });
handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false });

expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
});

it.each([
['pageup', -15],
['pagedown', 15],
])(
'handles page scroll %s (Command.PAGE_%s) with default size',
(key, expectedScroll) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);

const handler = mockUseKeypress.mock.calls[0][0];

handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false });

expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll);
},
);

it('respects scrollPageSize prop', () => {
render(
<ShellInputPrompt
activeShellPtyId={1}
focus={true}
scrollPageSize={10}
/>,
);

const handler = mockUseKeypress.mock.calls[0][0];

// PageDown
handler({
name: 'pagedown',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
expect(mockScrollPty).toHaveBeenCalledWith(1, 10);

// PageUp
handler({
name: 'pageup',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
expect(mockScrollPty).toHaveBeenCalledWith(1, -10);
});

it('does not handle input when not focused', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={false} />);

Expand Down Expand Up @@ -111,4 +159,21 @@ describe('ShellInputPrompt', () => {

expect(mockWriteToPty).not.toHaveBeenCalled();
});

it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);

const handler = mockUseKeypress.mock.calls[0][0];

const result = handler({
name: 'tab',
shift: true,
alt: false,
ctrl: false,
cmd: false,
});

expect(result).toBe(false);
expect(mockWriteToPty).not.toHaveBeenCalled();
});
});
25 changes: 20 additions & 5 deletions packages/cli/src/ui/components/ShellInputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
import { Command, keyMatchers } from '../keyMatchers.js';

export interface ShellInputPromptProps {
activeShellPtyId: number | null;
focus?: boolean;
scrollPageSize?: number;
}

export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
activeShellPtyId,
focus = true,
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
}) => {
const handleShellInputSubmit = useCallback(
(input: string) => {
Expand All @@ -34,21 +37,33 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
if (!focus || !activeShellPtyId) {
return false;
}

// Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}

if (key.ctrl && key.shift && key.name === 'up') {
// Allow Shift+Tab to bubble up for focus navigation
if (keyMatchers[Command.UNFOCUS_SHELL](key)) {
return false;
}

if (keyMatchers[Command.SCROLL_UP](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
}

if (key.ctrl && key.shift && key.name === 'down') {
if (keyMatchers[Command.SCROLL_DOWN](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return true;
}
// TODO: Check pty service actually scrolls (request)[https://github.com/google-gemini/gemini-cli/pull/17438/changes/c9fdaf8967da0036bfef43592fcab5a69537df35#r2776479023].
if (keyMatchers[Command.PAGE_UP](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize);
Comment thread
jwhelangoog marked this conversation as resolved.
return true;
}
if (keyMatchers[Command.PAGE_DOWN](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize);
return true;
}

const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
Expand All @@ -58,7 +73,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({

return false;
},
[focus, handleShellInputSubmit, activeShellPtyId],
[focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
);

useKeypress(handleInput, { isActive: focus });
Expand Down
Loading