From 4f2974dbfe36638915f1b08448d2563c64f88644 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:55:53 -0700 Subject: [PATCH] feat(ui): Improve UI layout adaptation for narrow terminals (#5651) Co-authored-by: Jacob Richman --- packages/cli/src/ui/App.test.tsx | 29 +++ packages/cli/src/ui/App.tsx | 15 +- .../src/ui/__snapshots__/App.test.tsx.snap | 17 +- packages/cli/src/ui/components/AsciiArt.ts | 11 ++ .../components/ContextSummaryDisplay.test.tsx | 85 +++++++++ .../ui/components/ContextSummaryDisplay.tsx | 54 +++--- .../cli/src/ui/components/Footer.test.tsx | 106 +++++++++++ packages/cli/src/ui/components/Footer.tsx | 167 ++++++++++-------- .../cli/src/ui/components/Header.test.tsx | 44 +++++ packages/cli/src/ui/components/Header.tsx | 22 ++- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../ui/components/LoadingIndicator.test.tsx | 70 ++++++++ .../src/ui/components/LoadingIndicator.tsx | 57 ++++-- .../src/ui/components/SuggestionsDisplay.tsx | 2 +- packages/cli/src/ui/utils/isNarrowWidth.ts | 9 + 15 files changed, 560 insertions(+), 132 deletions(-) create mode 100644 packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/Footer.test.tsx create mode 100644 packages/cli/src/ui/components/Header.test.tsx create mode 100644 packages/cli/src/ui/utils/isNarrowWidth.ts 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( + , + ); + 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={[ {!settings.merged.hideBanner && ( -
+
)} {!settings.merged.hideTips && } , @@ -994,9 +992,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {process.env.GEMINI_SYSTEM_MD && ( @@ -1021,7 +1020,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { /> )} - + {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && !shellModeActive && ( 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, +) => { + useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); + return render(); +}; + +describe('', () => { + 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 = ({ 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 = ({ } 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 ( + + Using: + {summaryParts.map((part, index) => ( + + {' '}- {part} + + ))} + + ); } - return {summaryText}; + return ( + + Using: {summaryParts.join(' | ')} + + ); }; 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(); + 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(