feat(ui): Improve UI layout adaptation for narrow terminals (#5651)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
65e4b941ee
commit
4f2974dbfe
|
@ -28,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
|
|||
import { EventEmitter } from 'events';
|
||||
import { updateEventEmitter } from '../utils/updateEventEmitter.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
|
||||
interface MockServerConfig {
|
||||
|
@ -243,6 +244,10 @@ vi.mock('../config/auth.js', () => ({
|
|||
validateAuthMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
|
||||
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
|
||||
await import('@google/gemini-cli-core'),
|
||||
|
@ -284,6 +289,11 @@ describe('App UI', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
||||
mockConfig = new ServerConfigMocked({
|
||||
embeddingModel: 'test-embedding-model',
|
||||
|
@ -1062,4 +1072,23 @@ describe('App UI', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -93,6 +93,7 @@ import { ShowMoreLines } from './components/ShowMoreLines.js';
|
|||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import { isNarrowWidth } from './utils/isNarrowWidth.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
|
@ -433,6 +434,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
|
||||
// Terminal and UI setup
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
|
@ -441,7 +443,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
20,
|
||||
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
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
|
@ -835,11 +837,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
items={[
|
||||
<Box flexDirection="column" key="header">
|
||||
{!settings.merged.hideBanner && (
|
||||
<Header
|
||||
terminalWidth={terminalWidth}
|
||||
version={version}
|
||||
nightly={nightly}
|
||||
/>
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!settings.merged.hideTips && <Tips config={config} />}
|
||||
</Box>,
|
||||
|
@ -994,9 +992,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
|
||||
<Box
|
||||
marginTop={1}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box>
|
||||
{process.env.GEMINI_SYSTEM_MD && (
|
||||
|
@ -1021,7 +1020,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!shellModeActive && (
|
||||
<AutoAcceptIndicator
|
||||
|
|
|
@ -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`] = `
|
||||
"
|
||||
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)"
|
||||
`;
|
||||
|
||||
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)"
|
||||
`;
|
||||
|
|
|
@ -25,3 +25,14 @@ export const longAsciiLogo = `
|
|||
███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -5,9 +5,11 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
|
@ -26,6 +28,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||
showToolDescriptions,
|
||||
ideContext,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
||||
|
@ -78,30 +82,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||
}
|
||||
parts.push(blockedText);
|
||||
}
|
||||
return 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(' | ');
|
||||
|
||||
let text = parts.join(', ');
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
summaryText += ' (ctrl+t to toggle)';
|
||||
text += ' (ctrl+t to toggle)';
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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\)/);
|
||||
});
|
||||
});
|
|
@ -10,11 +10,15 @@ import { Colors } from '../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';
|
||||
|
||||
interface FooterProps {
|
||||
model: string;
|
||||
targetDir: string;
|
||||
|
@ -43,21 +47,37 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
promptTokenCount,
|
||||
nightly,
|
||||
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>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={Colors.LightBlue}>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{displayPath}
|
||||
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
)}
|
||||
|
@ -70,10 +90,12 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
|
||||
{/* Middle Section: Centered Sandbox Info */}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexGrow={isNarrow ? 0 : 1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
justifyContent={isNarrow ? 'flex-start' : 'center'}
|
||||
display="flex"
|
||||
paddingX={isNarrow ? 0 : 1}
|
||||
paddingTop={isNarrow ? 1 : 0}
|
||||
>
|
||||
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
|
@ -92,9 +114,9 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
</Box>
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
<Box alignItems="center">
|
||||
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{' '}
|
||||
{isNarrow ? '' : ' '}
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
|
@ -120,4 +142,5 @@ export const Footer: React.FC<FooterProps> = ({
|
|||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -8,30 +8,34 @@ import React from 'react';
|
|||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
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 { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
terminalWidth: number; // For responsive logo
|
||||
version: string;
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
terminalWidth,
|
||||
version,
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle =
|
||||
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
{Colors.GradientColors ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
@ -536,7 +536,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
{completion.showSuggestions && (
|
||||
<Box>
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={completion.suggestions}
|
||||
activeIndex={completion.activeSuggestionIndex}
|
||||
|
@ -548,7 +548,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
</Box>
|
||||
)}
|
||||
{reverseSearchActive && (
|
||||
<Box>
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={reverseSearchCompletion.suggestions}
|
||||
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { vi } from 'vitest';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
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 = (
|
||||
ui: React.ReactElement,
|
||||
streamingStateValue: StreamingState,
|
||||
width = 120,
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
const contextValue: StreamingState = streamingStateValue;
|
||||
return render(
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
|
@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
|
|||
expect(output).toContain('This should 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
|
|||
import { StreamingState } from '../types.js';
|
||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
|
@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||
thought,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
if (streamingState === StreamingState.Idle) {
|
||||
return null;
|
||||
|
@ -34,9 +38,19 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||
|
||||
const primaryText = thought?.subject || currentLoadingPhrase;
|
||||
|
||||
const cancelAndTimerContent =
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={0} flexDirection="column">
|
||||
<Box paddingLeft={0} flexDirection="column">
|
||||
{/* Main loading line */}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
|
@ -47,15 +61,22 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||
}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
|
||||
<Text color={Colors.Gray}>
|
||||
{streamingState === StreamingState.WaitingForConfirmation
|
||||
? ''
|
||||
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
|
||||
</Text>
|
||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
||||
{rightContent && <Box>{rightContent}</Box>}
|
||||
{primaryText && (
|
||||
<Text color={Colors.AccentPurple}>{primaryText}</Text>
|
||||
)}
|
||||
{!isNarrow && cancelAndTimerContent && (
|
||||
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -82,7 +82,7 @@ export function SuggestionsDisplay({
|
|||
)}
|
||||
{suggestion.description ? (
|
||||
<Box flexGrow={1}>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function isNarrowWidth(width: number): boolean {
|
||||
return width < 80;
|
||||
}
|
Loading…
Reference in New Issue