feat(ui): add fullscreen toggle for integrated and background shells

This commit is contained in:
mkorwel
2026-03-15 18:39:00 -07:00
parent 17b37144a9
commit ade6dc0cc7
18 changed files with 361 additions and 119 deletions
@@ -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' && (
+135 -12
View File
@@ -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}>