mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 00:16:57 -07:00
feat(ui): add fullscreen toggle for integrated and background shells
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
@@ -70,6 +71,7 @@ export const BackgroundShellDisplay = ({
|
||||
isListOpenProp,
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const {
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
@@ -178,6 +180,10 @@ export const BackgroundShellDisplay = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_SHELL_FULLSCREEN](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
@@ -207,6 +213,9 @@ export const BackgroundShellDisplay = ({
|
||||
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
|
||||
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
|
||||
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
|
||||
...(settings.merged.experimental.fullscreen
|
||||
? [{ label: 'Fullscreen', command: Command.TOGGLE_SHELL_FULLSCREEN }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const helpTextStr = helpTextParts
|
||||
|
||||
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
|
||||
isExpandable?: boolean;
|
||||
isFirstThinking?: boolean;
|
||||
isFirstAfterThinking?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isExpandable,
|
||||
isFirstThinking = false,
|
||||
isFirstAfterThinking = false,
|
||||
isFullscreen = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -73,7 +75,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
key={itemForDisplay.id}
|
||||
width={terminalWidth}
|
||||
marginTop={needsTopMarginAfterThinking ? 1 : 0}
|
||||
marginTop={isFullscreen ? 0 : needsTopMarginAfterThinking ? 1 : 0}
|
||||
paddingTop={isFullscreen ? 1 : 0}
|
||||
>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
||||
@@ -197,9 +200,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
toolCalls={itemForDisplay.tools}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
borderTop={itemForDisplay.borderTop}
|
||||
borderBottom={itemForDisplay.borderBottom}
|
||||
borderTop={isFullscreen ? true : itemForDisplay.borderTop}
|
||||
borderBottom={isFullscreen ? true : itemForDisplay.borderBottom}
|
||||
isExpandable={isExpandable}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
|
||||
@@ -49,9 +49,14 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
cleanUiDetailsVisible,
|
||||
isForegroundShellFullscreen,
|
||||
terminalHeight,
|
||||
activePtyId,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
const fullscreenHeight = Math.max(terminalHeight - 7, 5);
|
||||
|
||||
const lastUserPromptIndex = useMemo(() => {
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const type = uiState.history[i].type;
|
||||
@@ -90,9 +95,11 @@ export const MainContent = () => {
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
isForegroundShellFullscreen
|
||||
? fullscreenHeight
|
||||
: uiState.constrainHeight || !isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.id}
|
||||
@@ -102,6 +109,7 @@ export const MainContent = () => {
|
||||
isExpandable={isExpandable}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isFullscreen={isForegroundShellFullscreen}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -111,6 +119,8 @@ export const MainContent = () => {
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
uiState.constrainHeight,
|
||||
isForegroundShellFullscreen,
|
||||
fullscreenHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -141,7 +151,11 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
|
||||
isForegroundShellFullscreen
|
||||
? fullscreenHeight
|
||||
: uiState.constrainHeight
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
@@ -149,6 +163,7 @@ export const MainContent = () => {
|
||||
isExpandable={true}
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isFullscreen={isForegroundShellFullscreen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -165,11 +180,41 @@ export const MainContent = () => {
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
isForegroundShellFullscreen,
|
||||
fullscreenHeight,
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
const virtualizedData = useMemo(() => {
|
||||
if (isForegroundShellFullscreen && activePtyId) {
|
||||
// Find the item that contains the active PTY
|
||||
const historyItem = uiState.history.find(
|
||||
(h) =>
|
||||
h.type === 'tool_group' &&
|
||||
h.tools.some((t) => t.ptyId === activePtyId),
|
||||
);
|
||||
if (historyItem) {
|
||||
return [
|
||||
{
|
||||
type: 'history' as const,
|
||||
item: historyItem,
|
||||
isExpandable: true,
|
||||
isFirstThinking: false,
|
||||
isFirstAfterThinking: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
const pendingItem = pendingHistoryItems.find(
|
||||
(h) =>
|
||||
h.type === 'tool_group' &&
|
||||
h.tools.some((t) => t.ptyId === activePtyId),
|
||||
);
|
||||
if (pendingItem) {
|
||||
return [{ type: 'pending' as const }];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map(
|
||||
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
|
||||
@@ -181,9 +226,14 @@ export const MainContent = () => {
|
||||
}),
|
||||
),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[augmentedHistory],
|
||||
);
|
||||
];
|
||||
}, [
|
||||
augmentedHistory,
|
||||
isForegroundShellFullscreen,
|
||||
activePtyId,
|
||||
uiState.history,
|
||||
pendingHistoryItems,
|
||||
]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
@@ -200,9 +250,11 @@ export const MainContent = () => {
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
isForegroundShellFullscreen
|
||||
? fullscreenHeight
|
||||
: uiState.constrainHeight || !item.isExpandable
|
||||
? staticAreaMaxItemHeight
|
||||
: undefined
|
||||
}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
@@ -212,9 +264,34 @@ export const MainContent = () => {
|
||||
isExpandable={item.isExpandable}
|
||||
isFirstThinking={item.isFirstThinking}
|
||||
isFirstAfterThinking={item.isFirstAfterThinking}
|
||||
isFullscreen={isForegroundShellFullscreen}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (isForegroundShellFullscreen && activePtyId) {
|
||||
const pendingItem = pendingHistoryItems.find(
|
||||
(h) =>
|
||||
h.type === 'tool_group' &&
|
||||
h.tools.some((t) => t.ptyId === activePtyId),
|
||||
);
|
||||
if (pendingItem) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<HistoryItemDisplay
|
||||
key={0}
|
||||
availableTerminalHeight={fullscreenHeight}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...pendingItem, id: 0 }}
|
||||
isPending={true}
|
||||
isExpandable={true}
|
||||
isFirstThinking={false}
|
||||
isFirstAfterThinking={false}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
@@ -226,9 +303,55 @@ export const MainContent = () => {
|
||||
pendingItems,
|
||||
uiState.constrainHeight,
|
||||
staticAreaMaxItemHeight,
|
||||
isForegroundShellFullscreen,
|
||||
fullscreenHeight,
|
||||
activePtyId,
|
||||
pendingHistoryItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (isForegroundShellFullscreen && activePtyId) {
|
||||
const historyItem = uiState.history.find(
|
||||
(h) =>
|
||||
h.type === 'tool_group' && h.tools.some((t) => t.ptyId === activePtyId),
|
||||
);
|
||||
if (historyItem) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} display="flex">
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={fullscreenHeight}
|
||||
key={historyItem.id}
|
||||
item={historyItem}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
isExpandable={true}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const pendingItem = pendingHistoryItems.find(
|
||||
(h) =>
|
||||
h.type === 'tool_group' && h.tools.some((t) => t.ptyId === activePtyId),
|
||||
);
|
||||
if (pendingItem) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} display="flex">
|
||||
<HistoryItemDisplay
|
||||
key={0}
|
||||
availableTerminalHeight={fullscreenHeight}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...pendingItem, id: 0 }}
|
||||
isPending={true}
|
||||
isExpandable={true}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<ScrollableList
|
||||
|
||||
@@ -38,37 +38,25 @@ import {
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
config?: Config;
|
||||
isExpandable?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
name,
|
||||
|
||||
description,
|
||||
|
||||
resultDisplay,
|
||||
|
||||
status,
|
||||
|
||||
availableTerminalHeight,
|
||||
|
||||
terminalWidth,
|
||||
|
||||
emphasis = 'medium',
|
||||
|
||||
renderOutputAsMarkdown = true,
|
||||
|
||||
ptyId,
|
||||
|
||||
config,
|
||||
|
||||
isFirst,
|
||||
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
|
||||
isExpandable,
|
||||
|
||||
isFullscreen,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
@@ -93,14 +81,18 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
availableTerminalHeight,
|
||||
constrainHeight,
|
||||
isExpandable,
|
||||
isFullscreen,
|
||||
});
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
isFullscreen,
|
||||
});
|
||||
|
||||
const lastDimensionsRef = React.useRef({ width: 0, height: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
const isExecuting = status === CoreToolCallStatus.Executing;
|
||||
if (isExecuting && ptyId) {
|
||||
@@ -109,11 +101,20 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
const finalHeight =
|
||||
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
|
||||
|
||||
ShellExecutionService.resizePty(
|
||||
ptyId,
|
||||
Math.max(1, childWidth),
|
||||
Math.max(1, finalHeight),
|
||||
);
|
||||
if (
|
||||
lastDimensionsRef.current.width !== childWidth ||
|
||||
lastDimensionsRef.current.height !== finalHeight
|
||||
) {
|
||||
ShellExecutionService.resizePty(
|
||||
ptyId,
|
||||
Math.max(1, childWidth),
|
||||
Math.max(1, finalHeight),
|
||||
);
|
||||
lastDimensionsRef.current = {
|
||||
width: childWidth,
|
||||
height: finalHeight,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
!(
|
||||
@@ -142,11 +143,8 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
const headerRef = React.useRef<DOMElement>(null);
|
||||
|
||||
const contentRef = React.useRef<DOMElement>(null);
|
||||
|
||||
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
|
||||
|
||||
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -156,7 +154,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
};
|
||||
|
||||
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
@@ -169,7 +166,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
<>
|
||||
<StickyHeader
|
||||
width={terminalWidth}
|
||||
isFirst={isFirst}
|
||||
isFirst={isFullscreen ? true : isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
containerRef={headerRef}
|
||||
@@ -216,6 +213,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={maxLines}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<ShellInputPrompt
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ToolGroupMessageProps {
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
isExpandable?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
@@ -48,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
isFullscreen,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
@@ -140,7 +142,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
const horizontalMargin = TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
const contentWidth = terminalWidth - horizontalMargin;
|
||||
|
||||
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
|
||||
// internal errors, plan-mode hidden write/edit), we should not emit standalone
|
||||
@@ -164,7 +167,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
paddingRight={horizontalMargin}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
const isFirst = index === 0;
|
||||
@@ -182,6 +185,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
isFullscreen,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -226,7 +230,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
height={isFullscreen ? 1 : 0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
embeddedShellFocused?: boolean;
|
||||
ptyId?: number;
|
||||
config?: Config;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ToolResultDisplayProps {
|
||||
maxLines?: number;
|
||||
hasFocus?: boolean;
|
||||
overflowDirection?: 'top' | 'bottom';
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
interface FileDiffResult {
|
||||
@@ -49,6 +50,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
maxLines,
|
||||
hasFocus = false,
|
||||
overflowDirection = 'top',
|
||||
isFullscreen = false,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -57,6 +59,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
availableTerminalHeight,
|
||||
isAlternateBuffer,
|
||||
maxLinesLimit: maxLines,
|
||||
isFullscreen,
|
||||
});
|
||||
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
@@ -173,11 +176,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
// Virtualized path for large ANSI arrays
|
||||
if (Array.isArray(resultDisplay)) {
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
const listHeight = isFullscreen
|
||||
? limit
|
||||
: Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
|
||||
Reference in New Issue
Block a user