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 { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)"
|
||||||
|
`;
|
||||||
|
|
|
@ -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 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(', ');
|
||||||
|
// Add ctrl+t hint when MCP servers are available
|
||||||
|
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||||
|
if (showToolDescriptions) {
|
||||||
|
text += ' (ctrl+t to toggle)';
|
||||||
|
} else {
|
||||||
|
text += ' (ctrl+t to view)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let summaryText = 'Using: ';
|
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
|
||||||
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
|
if (isNarrow) {
|
||||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
return (
|
||||||
if (showToolDescriptions) {
|
<Box flexDirection="column">
|
||||||
summaryText += ' (ctrl+t to toggle)';
|
<Text color={Colors.Gray}>Using:</Text>
|
||||||
} else {
|
{summaryParts.map((part, index) => (
|
||||||
summaryText += ' (ctrl+t to view)';
|
<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 { 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,81 +47,100 @@ export const Footer: React.FC<FooterProps> = ({
|
||||||
promptTokenCount,
|
promptTokenCount,
|
||||||
nightly,
|
nightly,
|
||||||
vimMode,
|
vimMode,
|
||||||
}) => (
|
}) => {
|
||||||
<Box justifyContent="space-between" width="100%">
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
<Box>
|
|
||||||
{debugMode && <DebugProfiler />}
|
|
||||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
|
||||||
{nightly ? (
|
|
||||||
<Gradient colors={Colors.GradientColors}>
|
|
||||||
<Text>
|
|
||||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
|
||||||
{branchName && <Text> ({branchName}*)</Text>}
|
|
||||||
</Text>
|
|
||||||
</Gradient>
|
|
||||||
) : (
|
|
||||||
<Text color={Colors.LightBlue}>
|
|
||||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
|
||||||
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{debugMode && (
|
|
||||||
<Text color={Colors.AccentRed}>
|
|
||||||
{' ' + (debugMessage || '--debug')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Middle Section: Centered Sandbox Info */}
|
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
|
<Box
|
||||||
flexGrow={1}
|
justifyContent="space-between"
|
||||||
alignItems="center"
|
width="100%"
|
||||||
justifyContent="center"
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
display="flex"
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
>
|
>
|
||||||
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
<Box>
|
||||||
<Text color="green">
|
{debugMode && <DebugProfiler />}
|
||||||
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
|
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||||
</Text>
|
{nightly ? (
|
||||||
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
<Gradient colors={Colors.GradientColors}>
|
||||||
<Text color={Colors.AccentYellow}>
|
<Text>
|
||||||
macOS Seatbelt{' '}
|
{displayPath}
|
||||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
{branchName && <Text> ({branchName}*)</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
</Gradient>
|
||||||
<Text color={Colors.AccentRed}>
|
) : (
|
||||||
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
|
<Text color={Colors.LightBlue}>
|
||||||
</Text>
|
{displayPath}
|
||||||
)}
|
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
||||||
</Box>
|
</Text>
|
||||||
|
)}
|
||||||
|
{debugMode && (
|
||||||
|
<Text color={Colors.AccentRed}>
|
||||||
|
{' ' + (debugMessage || '--debug')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Right Section: Gemini Label and Console Summary */}
|
{/* Middle Section: Centered Sandbox Info */}
|
||||||
<Box alignItems="center">
|
<Box
|
||||||
<Text color={Colors.AccentBlue}>
|
flexGrow={isNarrow ? 0 : 1}
|
||||||
{' '}
|
alignItems="center"
|
||||||
{model}{' '}
|
justifyContent={isNarrow ? 'flex-start' : 'center'}
|
||||||
<ContextUsageDisplay
|
display="flex"
|
||||||
promptTokenCount={promptTokenCount}
|
paddingX={isNarrow ? 0 : 1}
|
||||||
model={model}
|
paddingTop={isNarrow ? 1 : 0}
|
||||||
/>
|
>
|
||||||
</Text>
|
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
||||||
{corgiMode && (
|
<Text color="green">
|
||||||
<Text>
|
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
|
||||||
<Text color={Colors.Gray}>| </Text>
|
</Text>
|
||||||
<Text color={Colors.AccentRed}>▼</Text>
|
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
||||||
<Text color={Colors.Foreground}>(´</Text>
|
<Text color={Colors.AccentYellow}>
|
||||||
<Text color={Colors.AccentRed}>ᴥ</Text>
|
macOS Seatbelt{' '}
|
||||||
<Text color={Colors.Foreground}>`)</Text>
|
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
||||||
<Text color={Colors.AccentRed}>▼ </Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={Colors.AccentRed}>
|
||||||
|
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Section: Gemini Label and Console Summary */}
|
||||||
|
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
|
||||||
|
<Text color={Colors.AccentBlue}>
|
||||||
|
{isNarrow ? '' : ' '}
|
||||||
|
{model}{' '}
|
||||||
|
<ContextUsageDisplay
|
||||||
|
promptTokenCount={promptTokenCount}
|
||||||
|
model={model}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{corgiMode && (
|
||||||
{!showErrorDetails && errorCount > 0 && (
|
<Text>
|
||||||
<Box>
|
<Text color={Colors.Gray}>| </Text>
|
||||||
<Text color={Colors.Gray}>| </Text>
|
<Text color={Colors.AccentRed}>▼</Text>
|
||||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
<Text color={Colors.Foreground}>(´</Text>
|
||||||
</Box>
|
<Text color={Colors.AccentRed}>ᴥ</Text>
|
||||||
)}
|
<Text color={Colors.Foreground}>`)</Text>
|
||||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
<Text color={Colors.AccentRed}>▼ </Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!showErrorDetails && errorCount > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.Gray}>| </Text>
|
||||||
|
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||||
|
</Box>
|
||||||
</Box>
|
</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 { 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">
|
||||||
<Gradient colors={Colors.GradientColors}>
|
{Colors.GradientColors ? (
|
||||||
|
<Gradient colors={Colors.GradientColors}>
|
||||||
|
<Text>v{version}</Text>
|
||||||
|
</Gradient>
|
||||||
|
) : (
|
||||||
<Text>v{version}</Text>
|
<Text>v{version}</Text>
|
||||||
</Gradient>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,28 +38,45 @@ 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>
|
<Box
|
||||||
<Box marginRight={1}>
|
width="100%"
|
||||||
<GeminiRespondingSpinner
|
flexDirection={isNarrow ? 'column' : 'row'}
|
||||||
nonRespondingDisplay={
|
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||||
streamingState === StreamingState.WaitingForConfirmation
|
>
|
||||||
? '⠏'
|
<Box>
|
||||||
: ''
|
<Box marginRight={1}>
|
||||||
}
|
<GeminiRespondingSpinner
|
||||||
/>
|
nonRespondingDisplay={
|
||||||
|
streamingState === StreamingState.WaitingForConfirmation
|
||||||
|
? '⠏'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{primaryText && (
|
||||||
|
<Text color={Colors.AccentPurple}>{primaryText}</Text>
|
||||||
|
)}
|
||||||
|
{!isNarrow && cancelAndTimerContent && (
|
||||||
|
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
|
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
|
||||||
<Text color={Colors.Gray}>
|
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||||
{streamingState === StreamingState.WaitingForConfirmation
|
|
||||||
? ''
|
|
||||||
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
|
|
||||||
</Text>
|
|
||||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
|
||||||
{rightContent && <Box>{rightContent}</Box>}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
{isNarrow && cancelAndTimerContent && (
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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