Re-land bbiggs changes to reduce margin on narrow screens with fixes + full width setting (#10522)

This commit is contained in:
Jacob Richman
2025-10-09 19:27:20 -07:00
committed by GitHub
parent c82c2c2b15
commit 558be87311
25 changed files with 492 additions and 385 deletions

View File

@@ -59,7 +59,8 @@ import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useStdin, useStdout } from 'ink';
import { useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs';
import { basename } from 'node:path';
@@ -263,13 +264,14 @@ export const AppContainer = (props: AppContainerProps) => {
registerCleanup(consolePatcher.cleanup);
}, [handleNewMessage, config]);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
const { inputWidth, suggestionsWidth } =
calculatePromptWidths(terminalWidth);
calculatePromptWidths(mainAreaWidth);
return { inputWidth, suggestionsWidth };
}, [terminalWidth]);
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
}, [mainAreaWidth]);
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const isValidPath = useCallback((filePath: string): boolean => {

View File

@@ -29,7 +29,7 @@ describe('<AnsiOutputText />', () => {
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe('Hello, world!');
});
@@ -45,7 +45,7 @@ describe('<AnsiOutputText />', () => {
];
// Note: ink-testing-library doesn't render styles, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse');
});
@@ -58,7 +58,7 @@ describe('<AnsiOutputText />', () => {
];
// Note: ink-testing-library doesn't render colors, so we can only check the text.
// We are testing that it renders without crashing.
const { lastFrame } = render(<AnsiOutputText data={data} />);
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
expect(lastFrame()).toBe('Red FGBlue BG');
});
@@ -69,7 +69,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame } = render(<AnsiOutputText data={data} />);
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
@@ -85,7 +85,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} />,
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
@@ -99,7 +99,9 @@ describe('<AnsiOutputText />', () => {
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame } = render(<AnsiOutputText data={largeData} />);
const { lastFrame } = render(
<AnsiOutputText data={largeData} width={80} />,
);
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
});

View File

@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
const DEFAULT_HEIGHT = 24;
@@ -13,34 +13,40 @@ const DEFAULT_HEIGHT = 24;
interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
width: number;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
width,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
return lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex}>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.inverse ? token.bg : token.fg}
backgroundColor={token.inverse ? token.fg : token.bg}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
));
return (
<Box flexDirection="column" width={width} flexShrink={0}>
{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.inverse ? token.bg : token.fg}
backgroundColor={token.inverse ? token.fg : token.bg}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
))}
</Box>
);
};

View File

@@ -5,13 +5,12 @@
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useMemo } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
@@ -40,14 +39,8 @@ export const Composer = () => {
const { contextFileNames, showAutoAcceptIndicator } = uiState;
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
const { containerWidth } = useMemo(
() => calculatePromptWidths(uiState.terminalWidth),
[uiState.terminalWidth],
);
return (
<Box flexDirection="column">
<Box flexDirection="column" width={uiState.mainAreaWidth} flexShrink={0}>
{!uiState.embeddedShellFocused && (
<LoadingIndicator
thought={
@@ -124,7 +117,7 @@ export const Composer = () => {
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={containerWidth}
width={uiState.mainAreaWidth}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>

View File

@@ -51,10 +51,10 @@ describe('<ContextSummaryDisplay />', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+g to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
' Using:',
' - 1 open file (ctrl+g to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
@@ -75,10 +75,11 @@ describe('<ContextSummaryDisplay />', () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
contextFileNames: [],
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+g to view)'];
const expectedLines = [' Using:', ' - 1 open file (ctrl+g to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});

View File

@@ -98,7 +98,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
if (isNarrow) {
return (
<Box flexDirection="column">
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={theme.text.secondary}>
@@ -110,7 +110,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
return (
<Box>
<Box paddingX={1}>
<Text color={theme.text.secondary}>
Using: {summaryParts.join(' | ')}
</Text>

View File

@@ -11,15 +11,21 @@ import { tokenLimit } from '@google/gemini-cli-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
terminalWidth,
}: {
promptTokenCount: number;
model: string;
terminalWidth: number;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
return (
<Text color={theme.text.secondary}>
({((1 - percentage) * 100).toFixed(0)}% context left)
({percentageLeft}
{label})
</Text>
);
};

View File

@@ -4,20 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@google/gemini-cli-core';
import path from 'node:path';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
@@ -40,139 +32,93 @@ const defaultProps = {
branchName: 'main',
};
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => defaultProps.model),
getTargetDir: vi.fn(() => defaultProps.targetDir),
getDebugMode: vi.fn(() => false),
...overrides,
});
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
sessionStats: {
lastPromptTokenCount: 100,
},
branchName: defaultProps.branchName,
...overrides,
}) as UIState;
const createDefaultSettings = (
options: {
showMemoryUsage?: boolean;
hideCWD?: boolean;
hideSandboxStatus?: boolean;
hideModelInfo?: boolean;
} = {},
): LoadedSettings =>
({
merged: {
ui: {
showMemoryUsage: options.showMemoryUsage,
footer: {
hideCWD: options.hideCWD,
hideSandboxStatus: options.hideSandboxStatus,
hideModelInfo: options.hideModelInfo,
},
},
},
}) as never;
const renderWithWidth = (
width: number,
uiState: UIState,
settings: LoadedSettings = createDefaultSettings(),
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(
<ConfigContext.Provider value={createMockConfig() as never}>
<SettingsContext.Provider value={settings}>
<VimModeProvider settings={settings}>
<UIStateContext.Provider value={uiState}>
<Footer />
</UIStateContext.Provider>
</VimModeProvider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
const sessionStats = {
sessionStats: { lastPromptTokenCount: 0, lastResponseTokenCount: 0 },
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: defaultProps.branchName, ...sessionStats },
});
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
it('should display a shortened path on a narrow terminal', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { ...sessionStats },
});
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should display only the base directory name on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
const expectedPath = path.basename(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
const expectedPath =
'...' + tildePath.slice(tildePath.length - pathLength + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 80,
uiState: { ...sessionStats },
});
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
const expectedPath =
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
const tildePath = tildeifyPath(defaultProps.targetDir);
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
expect(lastFrame()).not.toContain(unexpectedPath);
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: defaultProps.branchName, ...sessionStats },
});
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
branchName: undefined,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: undefined, ...sessionStats },
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
});
it('displays the model name and abbreviated context percentage', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 99,
uiState: { ...sessionStats },
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
});
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, ...sessionStats },
});
expect(lastFrame()).toContain('untrusted');
});
it('should display custom sandbox info when SANDBOX env is set', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: undefined,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: undefined, ...sessionStats },
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
});
@@ -180,12 +126,10 @@ describe('<Footer />', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, ...sessionStats },
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
});
@@ -193,24 +137,20 @@ describe('<Footer />', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: true,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, ...sessionStats },
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
});
it('should prioritize untrusted message over sandbox info', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithWidth(
120,
createMockUIState({
isTrustedFolder: false,
}),
);
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, ...sessionStats },
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
@@ -219,51 +159,69 @@ describe('<Footer />', () => {
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
});
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
});
it('renders footer with all optional sections hidden (minimal footer)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
},
}),
);
});
expect(lastFrame()).toMatchSnapshot('footer-minimal');
});
it('renders footer with only model info hidden (partial filtering)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
}),
);
});
expect(lastFrame()).toMatchSnapshot('footer-no-model');
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
const { lastFrame } = renderWithWidth(
120,
createMockUIState(),
createDefaultSettings({
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
}),
);
});
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithWidth(79, createMockUIState());
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { ...sessionStats },
});
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});
});

View File

@@ -10,14 +10,10 @@ import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { isDevelopment } from '../../utils/installationInfo.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -43,6 +39,7 @@ export const Footer: React.FC = () => {
promptTokenCount,
nightly,
isTrustedFolder,
mainAreaWidth,
} = {
model: config.getModel(),
targetDir: config.getTargetDir(),
@@ -55,6 +52,7 @@ export const Footer: React.FC = () => {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
mainAreaWidth: uiState.mainAreaWidth,
};
const showMemoryUsage =
@@ -64,15 +62,8 @@ export const Footer: React.FC = () => {
settings.merged.ui?.footer?.hideSandboxStatus || false;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
// Adjust path length based on terminal width
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
const displayPath = isNarrow
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
@@ -82,9 +73,10 @@ export const Footer: React.FC = () => {
return (
<Box
justifyContent={justifyContent}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
width={mainAreaWidth}
flexDirection="row"
alignItems="center"
paddingX={1}
>
{(showDebugProfiler || displayVimMode || !hideCWD) && (
<Box>
@@ -119,12 +111,10 @@ export const Footer: React.FC = () => {
{/* Middle Section: Centered Trust/Sandbox Info */}
{!hideSandboxStatus && (
<Box
flexGrow={isNarrow || hideCWD || hideModelInfo ? 0 : 1}
flexGrow={1}
alignItems="center"
justifyContent={isNarrow || hideCWD ? 'flex-start' : 'center'}
justifyContent="center"
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{isTrustedFolder === false ? (
<Text color={theme.status.warning}>untrusted</Text>
@@ -142,45 +132,45 @@ export const Footer: React.FC = () => {
</Text>
) : (
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
no sandbox
{mainAreaWidth >= 100 && (
<Text color={theme.text.secondary}> (see /docs)</Text>
)}
</Text>
)}
</Box>
)}
{/* Right Section: Gemini Label and Console Summary */}
{(!hideModelInfo ||
showMemoryUsage ||
corgiMode ||
(!showErrorDetails && errorCount > 0)) && (
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
{!hideModelInfo && (
<Box alignItems="center">
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
)}
<Box alignItems="center" paddingLeft={2}>
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={mainAreaWidth}
/>
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center">
{corgiMode && (
<Text>
{!hideModelInfo && <Text color={theme.ui.comment}>| </Text>}
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
<Box paddingLeft={1} flexDirection="row">
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼</Text>
</Text>
</Box>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
{!hideModelInfo && <Text color={theme.ui.comment}>| </Text>}
<Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}

View File

@@ -37,6 +37,7 @@ import {
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -73,25 +74,16 @@ export interface InputPromptProps {
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
export const calculatePromptWidths = (mainContentWidth: number) => {
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
const suggestionsWidth = Math.max(20, mainContentWidth);
return {
inputWidth,
containerWidth,
inputWidth: Math.max(mainContentWidth - FRAME_OVERHEAD, 1),
containerWidth: mainContentWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
@@ -118,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { mainAreaWidth } = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -887,6 +880,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: theme.border.default
}
paddingX={1}
width={mainAreaWidth}
flexDirection="row"
alignItems="flex-start"
minHeight={3}
>
<Text
color={statusColor ?? theme.text.accent}

View File

@@ -50,7 +50,7 @@ export const MainContent = () => {
{(item) => item}
</Static>
<OverflowProvider>
<Box flexDirection="column">
<Box flexDirection="column" width={mainAreaWidth}>
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}

View File

@@ -1,20 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
"long (main*)
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
no sandbox (see /docs)
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...ectories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
gemini-pro (100% context left)"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
"...bar/and/some/more/directories/to/make/it/long no sandbox (see gemini-pro (100% context
(main*) /docs) left)"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `"...bar/and/some/more/directories/to/make/it/long (main*) no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...ectories/to/make/it/long no sandbox (see /docs)"`;

View File

@@ -1,57 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-collapsed-match 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
..."
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-expanded-match 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) commit │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) commit
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (r:) commit │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ (r:) commit
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ! Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ! Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ * Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ * Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -131,7 +131,7 @@ export const ToolConfirmationMessage: React.FC<
if (confirmationDetails.isModifying) {
return (
<Box
minWidth="90%"
width={terminalWidth}
borderStyle="round"
borderColor={theme.border.default}
justifyContent="space-around"

View File

@@ -96,8 +96,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width="100%"
marginLeft={1}
width={terminalWidth}
borderDimColor={
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
}

View File

@@ -171,6 +171,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
<AnsiOutputText
data={resultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
width={childWidth}
/>
)}
</Box>

View File

@@ -1,105 +1,108 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high)
│MockConfirmation: Confirm first tool
│MockTool[tool-2]: ? second-confirm - A tool for testing (low)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
│MockConfirmation: Confirm first tool │
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium)
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium)
│MockTool[tool-3]: o write_file - Write to file (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
│MockTool[tool-3]: o write_file - Write to file (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium)
│MockTool[tool-2]: o pending-tool - This tool is pending (medium)
│MockTool[tool-3]: x error-tool - This tool failed (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation (high)
│MockConfirmation: Are you sure you want to proceed?
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
│(high)
│MockConfirmation: Are you sure you want to proceed? │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium)
│MockTool[tool-2]: ✓ another-tool - Another tool (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ very-long-tool-name-that-might-wrap - This is a very long description that
│might cause wrapping issues (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────╮
│MockTool[tool-123]: ✓
│very-long-tool-name-that-might-wrap -
│This is a very long description that │
│might cause wrapping issues (medium) │
╰──────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium)
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium)
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
"──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
──────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -6,18 +6,16 @@
import { useEffect, useState } from 'react';
const TERMINAL_PADDING_X = 8;
export function useTerminalSize(): { columns: number; rows: number } {
const [size, setSize] = useState({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
columns: process.stdout.columns || 60,
rows: process.stdout.rows || 20,
});
useEffect(() => {
function updateSize() {
setSize({
columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
columns: process.stdout.columns || 60,
rows: process.stdout.rows || 20,
});
}

View File

@@ -17,7 +17,7 @@ export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
return (
<Box flexDirection="column" width="90%">
<Box flexDirection="column" width={uiState.mainAreaWidth}>
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
@@ -25,7 +25,7 @@ export const DefaultAppLayout: React.FC = () => {
{uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
terminalWidth={uiState.mainAreaWidth}
addItem={uiState.historyManager.addItem}
/>
) : (

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { lerp } from '../../utils/math.js';
import { type LoadedSettings } from '../../config/settings.js';
const getMainAreaWidthInternal = (terminalWidth: number): number => {
if (terminalWidth <= 80) {
return Math.round(0.98 * terminalWidth);
}
if (terminalWidth >= 132) {
return Math.round(0.9 * terminalWidth);
}
// Linearly interpolate between 80 columns (98%) and 132 columns (90%).
const t = (terminalWidth - 80) / (132 - 80);
const percentage = lerp(98, 90, t);
return Math.round(percentage * terminalWidth * 0.01);
};
export const calculateMainAreaWidth = (
terminalWidth: number,
settings: LoadedSettings,
): number =>
settings.merged.ui?.useFullWidth
? terminalWidth
: getMainAreaWidthInternal(terminalWidth);