diff options
| -rw-r--r-- | packages/cli/src/ui/App.test.tsx | 29 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 15 | ||||
| -rw-r--r-- | packages/cli/src/ui/__snapshots__/App.test.tsx.snap | 17 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/AsciiArt.ts | 11 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx | 85 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/ContextSummaryDisplay.tsx | 54 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/Footer.test.tsx | 106 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/Footer.tsx | 167 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/Header.test.tsx | 44 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/Header.tsx | 22 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/InputPrompt.tsx | 4 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/LoadingIndicator.test.tsx | 70 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/LoadingIndicator.tsx | 57 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/SuggestionsDisplay.tsx | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/utils/isNarrowWidth.ts | 9 |
15 files changed, 560 insertions, 132 deletions
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index a5c2a9c6..577133ca 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -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(); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index d311facf..a25b7a56 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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 diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 891a16af..f5425dba 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -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)" +`; diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index e15704dd..79eb522c 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -25,3 +25,14 @@ export const longAsciiLogo = ` ███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ ░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ `; + +export const tinyAsciiLogo = ` + ███ █████████ +░░░███ ███░░░░░███ + ░░░███ ███ ░░░ + ░░░███░███ + ███░ ░███ █████ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ +`; diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx new file mode 100644 index 00000000..d70bb4ca --- /dev/null +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -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); + }); +}); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index 78a19f0d..99406bd6 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -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 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 = []; - if (openFilesText) { - summaryParts.push(openFilesText); - } - if (geminiMdText) { - summaryParts.push(geminiMdText); - } - if (mcpText) { - summaryParts.push(mcpText); - } - summaryText += summaryParts.join(' | '); + const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean); - // Add ctrl+t hint when MCP servers are available - if (mcpServers && Object.keys(mcpServers).length > 0) { - if (showToolDescriptions) { - summaryText += ' (ctrl+t to toggle)'; - } else { - summaryText += ' (ctrl+t to view)'; - } + 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> + ); }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx new file mode 100644 index 00000000..5e79eea4 --- /dev/null +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -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\)/); + }); +}); diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 14cda5f3..7de47659 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -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,81 +47,100 @@ export const Footer: React.FC<FooterProps> = ({ promptTokenCount, nightly, vimMode, -}) => ( - <Box justifyContent="space-between" width="100%"> - <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> +}) => { + 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); - {/* Middle Section: Centered Sandbox Info */} + return ( <Box - flexGrow={1} - alignItems="center" - justifyContent="center" - display="flex" + justifyContent="space-between" + width="100%" + flexDirection={isNarrow ? 'column' : 'row'} + alignItems={isNarrow ? 'flex-start' : 'center'} > - {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? ( - <Text color="green"> - {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')} - </Text> - ) : process.env.SANDBOX === 'sandbox-exec' ? ( - <Text color={Colors.AccentYellow}> - macOS Seatbelt{' '} - <Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text> - </Text> - ) : ( - <Text color={Colors.AccentRed}> - no sandbox <Text color={Colors.Gray}>(see /docs)</Text> - </Text> - )} - </Box> + <Box> + {debugMode && <DebugProfiler />} + {vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>} + {nightly ? ( + <Gradient colors={Colors.GradientColors}> + <Text> + {displayPath} + {branchName && <Text> ({branchName}*)</Text>} + </Text> + </Gradient> + ) : ( + <Text color={Colors.LightBlue}> + {displayPath} + {branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>} + </Text> + )} + {debugMode && ( + <Text color={Colors.AccentRed}> + {' ' + (debugMessage || '--debug')} + </Text> + )} + </Box> + + {/* Middle Section: Centered Sandbox Info */} + <Box + flexGrow={isNarrow ? 0 : 1} + alignItems="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"> + {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')} + </Text> + ) : process.env.SANDBOX === 'sandbox-exec' ? ( + <Text color={Colors.AccentYellow}> + macOS Seatbelt{' '} + <Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</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"> - <Text color={Colors.AccentBlue}> - {' '} - {model}{' '} - <ContextUsageDisplay - promptTokenCount={promptTokenCount} - model={model} - /> - </Text> - {corgiMode && ( - <Text> - <Text color={Colors.Gray}>| </Text> - <Text color={Colors.AccentRed}>▼</Text> - <Text color={Colors.Foreground}>(´</Text> - <Text color={Colors.AccentRed}>ᴥ</Text> - <Text color={Colors.Foreground}>`)</Text> - <Text color={Colors.AccentRed}>▼ </Text> + {/* 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> - )} - {!showErrorDetails && errorCount > 0 && ( - <Box> - <Text color={Colors.Gray}>| </Text> - <ConsoleSummaryDisplay errorCount={errorCount} /> - </Box> - )} - {showMemoryUsage && <MemoryUsageDisplay />} + {corgiMode && ( + <Text> + <Text color={Colors.Gray}>| </Text> + <Text color={Colors.AccentRed}>▼</Text> + <Text color={Colors.Foreground}>(´</Text> + <Text color={Colors.AccentRed}>ᴥ</Text> + <Text color={Colors.Foreground}>`)</Text> + <Text color={Colors.AccentRed}>▼ </Text> + </Text> + )} + {!showErrorDetails && errorCount > 0 && ( + <Box> + <Text color={Colors.Gray}>| </Text> + <ConsoleSummaryDisplay errorCount={errorCount} /> + </Box> + )} + {showMemoryUsage && <MemoryUsageDisplay />} + </Box> </Box> - </Box> -); + ); +}; diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx new file mode 100644 index 00000000..95ed3f07 --- /dev/null +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -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'); + }); +}); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 4038e415..0894ad14 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -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"> - <Gradient colors={Colors.GradientColors}> + {Colors.GradientColors ? ( + <Gradient colors={Colors.GradientColors}> + <Text>v{version}</Text> + </Gradient> + ) : ( <Text>v{version}</Text> - </Gradient> + )} </Box> )} </Box> diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b405b684..7a7a9934 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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} diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 9e647208..e55cc090 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -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); + }); + }); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 6e1bc758..7ac356dd 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -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,28 +38,45 @@ 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> - <Box marginRight={1}> - <GeminiRespondingSpinner - nonRespondingDisplay={ - streamingState === StreamingState.WaitingForConfirmation - ? '⠏' - : '' - } - /> + <Box + width="100%" + flexDirection={isNarrow ? 'column' : 'row'} + alignItems={isNarrow ? 'flex-start' : 'center'} + > + <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> - {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>} + {!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> ); }; diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 9c4b5687..1275a911 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -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> diff --git a/packages/cli/src/ui/utils/isNarrowWidth.ts b/packages/cli/src/ui/utils/isNarrowWidth.ts new file mode 100644 index 00000000..5540a400 --- /dev/null +++ b/packages/cli/src/ui/utils/isNarrowWidth.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isNarrowWidth(width: number): boolean { + return width < 80; +} |
