feat(ui): Improve UI layout adaptation for narrow terminals (#5651)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Gal Zahavi 2025-08-07 15:55:53 -07:00 committed by GitHub
parent 65e4b941ee
commit 4f2974dbfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 560 additions and 132 deletions

View File

@ -28,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js'; import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import * as auth from '../config/auth.js'; import * as auth from '../config/auth.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
// Define a more complete mock server config based on actual Config // Define a more complete mock server config based on actual Config
interface MockServerConfig { interface MockServerConfig {
@ -243,6 +244,10 @@ vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn(), validateAuthMethod: vi.fn(),
})); }));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const mockedCheckForUpdates = vi.mocked(checkForUpdates); const mockedCheckForUpdates = vi.mocked(checkForUpdates);
const { isGitRepository: mockedIsGitRepository } = vi.mocked( const { isGitRepository: mockedIsGitRepository } = vi.mocked(
await import('@google/gemini-cli-core'), await import('@google/gemini-cli-core'),
@ -284,6 +289,11 @@ describe('App UI', () => {
}; };
beforeEach(() => { beforeEach(() => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 24,
});
const ServerConfigMocked = vi.mocked(ServerConfig, true); const ServerConfigMocked = vi.mocked(ServerConfig, true);
mockConfig = new ServerConfigMocked({ mockConfig = new ServerConfigMocked({
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
@ -1062,4 +1072,23 @@ describe('App UI', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled(); expect(validateAuthMethodSpy).not.toHaveBeenCalled();
}); });
}); });
describe('when in a narrow terminal', () => {
it('should render with a column layout', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 60,
rows: 24,
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toMatchSnapshot();
});
});
}); });

View File

@ -93,6 +93,7 @@ import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js'; import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@ -433,6 +434,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Terminal and UI setup // Terminal and UI setup
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const { stdin, setRawMode } = useStdin(); const { stdin, setRawMode } = useStdin();
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
@ -441,7 +443,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
20, 20,
Math.floor(terminalWidth * widthFraction) - 3, Math.floor(terminalWidth * widthFraction) - 3,
); );
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
// Utility callbacks // Utility callbacks
const isValidPath = useCallback((filePath: string): boolean => { const isValidPath = useCallback((filePath: string): boolean => {
@ -835,11 +837,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
items={[ items={[
<Box flexDirection="column" key="header"> <Box flexDirection="column" key="header">
{!settings.merged.hideBanner && ( {!settings.merged.hideBanner && (
<Header <Header version={version} nightly={nightly} />
terminalWidth={terminalWidth}
version={version}
nightly={nightly}
/>
)} )}
{!settings.merged.hideTips && <Tips config={config} />} {!settings.merged.hideTips && <Tips config={config} />}
</Box>, </Box>,
@ -994,9 +992,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box <Box
marginTop={1} marginTop={1}
display="flex"
justifyContent="space-between" justifyContent="space-between"
width="100%" width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
> >
<Box> <Box>
{process.env.GEMINI_SYSTEM_MD && ( {process.env.GEMINI_SYSTEM_MD && (
@ -1021,7 +1020,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
/> />
)} )}
</Box> </Box>
<Box> <Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT && {showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!shellModeActive && ( !shellModeActive && (
<AutoAcceptIndicator <AutoAcceptIndicator

View File

@ -10,9 +10,22 @@ exports[`App UI > should render correctly with the prompt input box 1`] = `
`; `;
exports[`App UI > should render the initial UI correctly 1`] = ` exports[`App UI > should render the initial UI correctly 1`] = `
" " I'm Feeling Lucky (esc to cancel, 0s)
I'm Feeling Lucky (esc to cancel, 0s)
/test/dir no sandbox (see /docs) model (100% context left)" /test/dir no sandbox (see /docs) model (100% context left)"
`; `;
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
"
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰────────────────────────────────────────────────────────────────────────────────────────╯
dir
no sandbox (see /docs)
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
`;

View File

@ -25,3 +25,14 @@ export const longAsciiLogo = `
`; `;
export const tinyAsciiLogo = `
`;

View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
};
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
showToolDescriptions: false,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c' }],
},
},
};
it('should render on a single line on a wide screen', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
expect(output).toContain(
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)',
);
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+e to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
});
it('should switch layout at the 80-column breakpoint', () => {
// At 80 columns, should be on one line
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
expect(wideFrame().includes('\n')).toBe(false);
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
expect(narrowFrame().includes('\n')).toBe(true);
expect(narrowFrame().split('\n').length).toBe(4);
});
it('should not render empty parts', () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});
});

View File

@ -5,9 +5,11 @@
*/ */
import React from 'react'; import React from 'react';
import { Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core'; import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps { interface ContextSummaryDisplayProps {
geminiMdFileCount: number; geminiMdFileCount: number;
@ -26,6 +28,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
showToolDescriptions, showToolDescriptions,
ideContext, ideContext,
}) => { }) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length; const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0; const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@ -78,30 +82,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
} }
parts.push(blockedText); parts.push(blockedText);
} }
return parts.join(', '); let text = parts.join(', ');
})();
let summaryText = 'Using: ';
const summaryParts = [];
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
if (mcpText) {
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
// Add ctrl+t hint when MCP servers are available // Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) { if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) { if (showToolDescriptions) {
summaryText += ' (ctrl+t to toggle)'; text += ' (ctrl+t to toggle)';
} else { } else {
summaryText += ' (ctrl+t to view)'; text += ' (ctrl+t to view)';
} }
} }
return text;
})();
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={Colors.Gray}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={Colors.Gray}>
{' '}- {part}
</Text>
))}
</Box>
);
}
return <Text color={Colors.Gray}>{summaryText}</Text>; return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
</Box>
);
}; };

View File

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@google/gemini-cli-core';
import path from 'node:path';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
shortenPath: (p: string, len: number) => {
if (p.length > len) {
return '...' + p.slice(p.length - len + 3);
}
return p;
},
};
});
const defaultProps = {
model: 'gemini-pro',
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
debugMode: false,
debugMessage: '',
corgiMode: false,
errorCount: 0,
showErrorDetails: false,
showMemoryUsage: false,
promptTokenCount: 100,
nightly: false,
};
const renderWithWidth = (width: number, props = defaultProps) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<Footer {...props} />);
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120);
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);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79);
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);
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
branchName: undefined,
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
});

View File

@ -10,11 +10,15 @@ import { Colors } from '../colors.js';
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core'; import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process'; import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js'; import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps { interface FooterProps {
model: string; model: string;
targetDir: string; targetDir: string;
@ -43,21 +47,37 @@ export const Footer: React.FC<FooterProps> = ({
promptTokenCount, promptTokenCount,
nightly, nightly,
vimMode, vimMode,
}) => ( }) => {
<Box justifyContent="space-between" width="100%"> 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);
return (
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box> <Box>
{debugMode && <DebugProfiler />} {debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>} {vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? ( {nightly ? (
<Gradient colors={Colors.GradientColors}> <Gradient colors={Colors.GradientColors}>
<Text> <Text>
{shortenPath(tildeifyPath(targetDir), 70)} {displayPath}
{branchName && <Text> ({branchName}*)</Text>} {branchName && <Text> ({branchName}*)</Text>}
</Text> </Text>
</Gradient> </Gradient>
) : ( ) : (
<Text color={Colors.LightBlue}> <Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(targetDir), 70)} {displayPath}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>} {branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
</Text> </Text>
)} )}
@ -70,10 +90,12 @@ export const Footer: React.FC<FooterProps> = ({
{/* Middle Section: Centered Sandbox Info */} {/* Middle Section: Centered Sandbox Info */}
<Box <Box
flexGrow={1} flexGrow={isNarrow ? 0 : 1}
alignItems="center" alignItems="center"
justifyContent="center" justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex" display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
> >
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? ( {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green"> <Text color="green">
@ -92,9 +114,9 @@ export const Footer: React.FC<FooterProps> = ({
</Box> </Box>
{/* Right Section: Gemini Label and Console Summary */} {/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center"> <Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
<Text color={Colors.AccentBlue}> <Text color={Colors.AccentBlue}>
{' '} {isNarrow ? '' : ' '}
{model}{' '} {model}{' '}
<ContextUsageDisplay <ContextUsageDisplay
promptTokenCount={promptTokenCount} promptTokenCount={promptTokenCount}
@ -120,4 +142,5 @@ export const Footer: React.FC<FooterProps> = ({
{showMemoryUsage && <MemoryUsageDisplay />} {showMemoryUsage && <MemoryUsageDisplay />}
</Box> </Box>
</Box> </Box>
); );
};

View File

@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
vi.mock('../hooks/useTerminalSize.js');
describe('<Header />', () => {
beforeEach(() => {});
it('renders the long logo on a wide terminal', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).toContain(longAsciiLogo);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).not.toContain('v1.0.0');
});
});

View File

@ -8,30 +8,34 @@ import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js'; import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js'; import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
interface HeaderProps { interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string; version: string;
nightly: boolean; nightly: boolean;
} }
export const Header: React.FC<HeaderProps> = ({ export const Header: React.FC<HeaderProps> = ({
customAsciiArt, customAsciiArt,
terminalWidth,
version, version,
nightly, nightly,
}) => { }) => {
const { columns: terminalWidth } = useTerminalSize();
let displayTitle; let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo); const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
if (customAsciiArt) { if (customAsciiArt) {
displayTitle = customAsciiArt; displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
} else { } else {
displayTitle = displayTitle = tinyAsciiLogo;
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
} }
const artWidth = getAsciiArtWidth(displayTitle); const artWidth = getAsciiArtWidth(displayTitle);
@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
)} )}
{nightly && ( {nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end"> <Box width="100%" flexDirection="row" justifyContent="flex-end">
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}> <Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text> <Text>v{version}</Text>
</Gradient> </Gradient>
) : (
<Text>v{version}</Text>
)}
</Box> </Box>
)} )}
</Box> </Box>

View File

@ -536,7 +536,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box> </Box>
</Box> </Box>
{completion.showSuggestions && ( {completion.showSuggestions && (
<Box> <Box paddingRight={2}>
<SuggestionsDisplay <SuggestionsDisplay
suggestions={completion.suggestions} suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex} activeIndex={completion.activeSuggestionIndex}
@ -548,7 +548,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box> </Box>
)} )}
{reverseSearchActive && ( {reverseSearchActive && (
<Box> <Box paddingRight={2}>
<SuggestionsDisplay <SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions} suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex} activeIndex={reverseSearchCompletion.activeSuggestionIndex}

View File

@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js'; import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { vi } from 'vitest'; import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner // Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({ vi.mock('./GeminiRespondingSpinner.js', () => ({
@ -29,10 +30,18 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
}, },
})); }));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = ( const renderWithContext = (
ui: React.ReactElement, ui: React.ReactElement,
streamingStateValue: StreamingState, streamingStateValue: StreamingState,
width = 120,
) => { ) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const contextValue: StreamingState = streamingStateValue; const contextValue: StreamingState = streamingStateValue;
return render( return render(
<StreamingContext.Provider value={contextValue}> <StreamingContext.Provider value={contextValue}>
@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('This should be displayed'); expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed'); expect(output).not.toContain('This should not be displayed');
}); });
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
});
it('should render on multiple lines on a narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
const output = lastFrame();
const lines = output?.split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
expect(lastFrame()?.includes('\n')).toBe(false);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
}); });

View File

@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js'; import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface LoadingIndicatorProps { interface LoadingIndicatorProps {
currentLoadingPhrase?: string; currentLoadingPhrase?: string;
@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
thought, thought,
}) => { }) => {
const streamingState = useStreamingContext(); const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) { if (streamingState === StreamingState.Idle) {
return null; return null;
@ -34,9 +38,19 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase; const primaryText = thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
return ( return (
<Box marginTop={1} paddingLeft={0} flexDirection="column"> <Box paddingLeft={0} flexDirection="column">
{/* Main loading line */} {/* Main loading line */}
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box> <Box>
<Box marginRight={1}> <Box marginRight={1}>
<GeminiRespondingSpinner <GeminiRespondingSpinner
@ -47,15 +61,22 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
} }
/> />
</Box> </Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>} {primaryText && (
<Text color={Colors.Gray}> <Text color={Colors.AccentPurple}>{primaryText}</Text>
{streamingState === StreamingState.WaitingForConfirmation )}
? '' {!isNarrow && cancelAndTimerContent && (
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`} <Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
</Text> )}
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
</Box> </Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box> </Box>
); );
}; };

View File

@ -82,7 +82,7 @@ export function SuggestionsDisplay({
)} )}
{suggestion.description ? ( {suggestion.description ? (
<Box flexGrow={1}> <Box flexGrow={1}>
<Text color={textColor} wrap="wrap"> <Text color={textColor} wrap="truncate">
{suggestion.description} {suggestion.description}
</Text> </Text>
</Box> </Box>

View File

@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export function isNarrowWidth(width: number): boolean {
return width < 80;
}