diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 70096ea958..9e9f71d5c0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -411,6 +411,15 @@ const SETTINGS_SCHEMA = { description: 'Show citations for generated text in the chat.', showInDialog: true, }, + useFullWidth: { + type: 'boolean', + label: 'Use Full Width', + category: 'UI', + requiresRestart: false, + default: false, + description: 'Use the entire width of the terminal for output.', + showInDialog: true, + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a0de0d6165..89b80329a3 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -482,7 +482,7 @@ describe('startInteractiveUI', () => { // Verify all startup tasks were called expect(getCliVersion).toHaveBeenCalledTimes(1); - expect(registerCleanup).toHaveBeenCalledTimes(1); + expect(registerCleanup).toHaveBeenCalledTimes(2); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 1182d2de6a..e25b8bff1a 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -145,6 +145,22 @@ export async function startInteractiveUI( workspaceRoot: string = process.cwd(), initializationResult: InitializationResult, ) { + // Disable line wrapping. + // We rely on Ink to manage all line wrapping by forcing all content to be + // narrower than the terminal width so there is no need for the terminal to + // also attempt line wrapping. + // Disabling line wrapping reduces Ink rendering artifacts particularly when + // the terminal is resized on terminals that full respect this escape code + // such as Ghostty. Some terminals such as Iterm2 only respect line wrapping + // when using the alternate buffer, which Gemini CLI does not use because we + // do not yet have support for scrolling in that mode. + process.stdout.write('\x1b[?7l'); + + registerCleanup(() => { + // Re-enable line wrapping on exit. + process.stdout.write('\x1b[?7h'); + }); + const version = await getCliVersion(); setWindowTitle(basename(workspaceRoot), settings); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 690d765d80..5002e34a29 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -6,12 +6,34 @@ import { render } from 'ink-testing-library'; import type React from 'react'; -import { LoadedSettings } from '../config/settings.js'; +import { LoadedSettings, type Settings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; +import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; +import { ConfigContext } from '../ui/contexts/ConfigContext.js'; +import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; +import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; -const mockSettings = new LoadedSettings( +import { type Config } from '@google/gemini-cli-core'; + +const mockConfig = { + getModel: () => 'gemini-pro', + getTargetDir: () => + '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', + getDebugMode: () => false, +}; + +const configProxy = new Proxy(mockConfig, { + get(target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + throw new Error(`mockConfig does not have property ${String(prop)}`); + }, +}); + +export const mockSettings = new LoadedSettings( { path: '', settings: {}, originalSettings: {} }, { path: '', settings: {}, originalSettings: {} }, { path: '', settings: {}, originalSettings: {} }, @@ -20,16 +42,83 @@ const mockSettings = new LoadedSettings( new Set(), ); +export const createMockSettings = ( + overrides: Partial, +): LoadedSettings => { + const settings = overrides as Settings; + return new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings, originalSettings: settings }, + { path: '', settings: {}, originalSettings: {} }, + true, + new Set(), + ); +}; + +// A minimal mock UIState to satisfy the context provider. +// Tests that need specific UIState values should provide their own. +const baseMockUiState = { + mainAreaWidth: 100, + terminalWidth: 120, +}; + export const renderWithProviders = ( component: React.ReactElement, - { shellFocus = true, settings = mockSettings } = {}, -): ReturnType => - render( - - - - {component} - - - , + { + shellFocus = true, + settings = mockSettings, + uiState: providedUiState, + width, + config = configProxy as unknown as Config, + }: { + shellFocus?: boolean; + settings?: LoadedSettings; + uiState?: Partial; + width?: number; + config?: Config; + } = {}, +): ReturnType => { + const baseState: UIState = new Proxy( + { ...baseMockUiState, ...providedUiState }, + { + get(target, prop) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + // For properties not in the base mock or provided state, + // we'll check the original proxy to see if it's a defined but + // unprovided property, and if not, throw. + if (prop in baseMockUiState) { + return baseMockUiState[prop as keyof typeof baseMockUiState]; + } + throw new Error(`mockUiState does not have property ${String(prop)}`); + }, + }, + ) as UIState; + + const terminalWidth = width ?? baseState.terminalWidth; + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); + + const finalUiState = { + ...baseState, + terminalWidth, + mainAreaWidth, + }; + + return render( + + + + + + + {component} + + + + + + , ); +}; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eb03394611..f8bdda1899 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -59,7 +59,8 @@ import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; -import { useStdin, useStdout } from 'ink'; +import { useStdout, useStdin } from 'ink'; +import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; import { basename } from 'node:path'; @@ -263,13 +264,14 @@ export const AppContainer = (props: AppContainerProps) => { registerCleanup(consolePatcher.cleanup); }, [handleNewMessage, config]); + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = - calculatePromptWidths(terminalWidth); + calculatePromptWidths(mainAreaWidth); return { inputWidth, suggestionsWidth }; - }, [terminalWidth]); - const mainAreaWidth = Math.floor(terminalWidth * 0.9); + }, [mainAreaWidth]); + const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); const isValidPath = useCallback((filePath: string): boolean => { diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 5bb3673e73..3495a27b4f 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,7 +29,7 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('Hello, world!'); }); @@ -45,7 +45,7 @@ describe('', () => { ]; // Note: ink-testing-library doesn't render styles, so we can only check the text. // We are testing that it renders without crashing. - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse'); }); @@ -58,7 +58,7 @@ describe('', () => { ]; // Note: ink-testing-library doesn't render colors, so we can only check the text. // We are testing that it renders without crashing. - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toBe('Red FGBlue BG'); }); @@ -69,7 +69,7 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame } = render(); + const { lastFrame } = render(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output!.split('\n'); @@ -85,7 +85,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).not.toContain('Line 1'); @@ -99,7 +99,9 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame } = render(); + const { lastFrame } = render( + , + ); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index 2a714f7cf4..8b54eb6453 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; const DEFAULT_HEIGHT = 24; @@ -13,34 +13,40 @@ const DEFAULT_HEIGHT = 24; interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; + width: number; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, + width, }) => { const lastLines = data.slice( -(availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight : DEFAULT_HEIGHT), ); - return lastLines.map((line: AnsiLine, lineIndex: number) => ( - - {line.length > 0 - ? line.map((token: AnsiToken, tokenIndex: number) => ( - - {token.text} - - )) - : null} - - )); + return ( + + {lastLines.map((line: AnsiLine, lineIndex: number) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + ))} + + ); }; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 2c5d0f16b7..1d4e942624 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,13 +5,12 @@ */ import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { useMemo } from 'react'; import { LoadingIndicator } from './LoadingIndicator.js'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { InputPrompt, calculatePromptWidths } from './InputPrompt.js'; +import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; @@ -40,14 +39,8 @@ export const Composer = () => { const { contextFileNames, showAutoAcceptIndicator } = uiState; - // Use the container width of InputPrompt for width of DetailedMessagesDisplay - const { containerWidth } = useMemo( - () => calculatePromptWidths(uiState.terminalWidth), - [uiState.terminalWidth], - ); - return ( - + {!uiState.embeddedShellFocused && ( { maxHeight={ uiState.constrainHeight ? debugConsoleMaxHeight : undefined } - width={containerWidth} + width={uiState.mainAreaWidth} /> diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index 1c77c2a859..40098a14ca 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -51,10 +51,10 @@ describe('', () => { const { lastFrame } = renderWithWidth(60, baseProps); const output = lastFrame(); const expectedLines = [ - 'Using:', - ' - 1 open file (ctrl+g to view)', - ' - 1 GEMINI.md file', - ' - 1 MCP server (ctrl+t to view)', + ' Using:', + ' - 1 open file (ctrl+g to view)', + ' - 1 GEMINI.md file', + ' - 1 MCP server (ctrl+t to view)', ]; const actualLines = output.split('\n'); expect(actualLines).toEqual(expectedLines); @@ -75,10 +75,11 @@ describe('', () => { const props = { ...baseProps, geminiMdFileCount: 0, + contextFileNames: [], mcpServers: {}, }; const { lastFrame } = renderWithWidth(60, props); - const expectedLines = ['Using:', ' - 1 open file (ctrl+g to view)']; + const expectedLines = [' Using:', ' - 1 open file (ctrl+g 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 388bd44edc..be38b12584 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -98,7 +98,7 @@ export const ContextSummaryDisplay: React.FC = ({ if (isNarrow) { return ( - + Using: {summaryParts.map((part, index) => ( @@ -110,7 +110,7 @@ export const ContextSummaryDisplay: React.FC = ({ } return ( - + Using: {summaryParts.join(' | ')} diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index d7d8c063f0..25dad9c7e3 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -11,15 +11,21 @@ import { tokenLimit } from '@google/gemini-cli-core'; export const ContextUsageDisplay = ({ promptTokenCount, model, + terminalWidth, }: { promptTokenCount: number; model: string; + terminalWidth: number; }) => { const percentage = promptTokenCount / tokenLimit(model); + const percentageLeft = ((1 - percentage) * 100).toFixed(0); + + const label = terminalWidth < 100 ? '%' : '% context left'; return ( - ({((1 - percentage) * 100).toFixed(0)}% context left) + ({percentageLeft} + {label}) ); }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 3c0a4d9b96..5d7df49610 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -4,20 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi } from 'vitest'; +import { + renderWithProviders, + createMockSettings, +} from '../../test-utils/render.js'; import { Footer } from './Footer.js'; -import * as useTerminalSize from '../hooks/useTerminalSize.js'; import { tildeifyPath } from '@google/gemini-cli-core'; -import path from 'node:path'; -import { type UIState, UIStateContext } from '../contexts/UIStateContext.js'; -import { ConfigContext } from '../contexts/ConfigContext.js'; -import { SettingsContext } from '../contexts/SettingsContext.js'; -import type { LoadedSettings } from '../../config/settings.js'; -import { VimModeProvider } from '../contexts/VimModeContext.js'; - -vi.mock('../hooks/useTerminalSize.js'); -const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -40,139 +32,93 @@ const defaultProps = { branchName: 'main', }; -const createMockConfig = (overrides = {}) => ({ - getModel: vi.fn(() => defaultProps.model), - getTargetDir: vi.fn(() => defaultProps.targetDir), - getDebugMode: vi.fn(() => false), - ...overrides, -}); - -const createMockUIState = (overrides: Partial = {}): UIState => - ({ - sessionStats: { - lastPromptTokenCount: 100, - }, - branchName: defaultProps.branchName, - ...overrides, - }) as UIState; - -const createDefaultSettings = ( - options: { - showMemoryUsage?: boolean; - hideCWD?: boolean; - hideSandboxStatus?: boolean; - hideModelInfo?: boolean; - } = {}, -): LoadedSettings => - ({ - merged: { - ui: { - showMemoryUsage: options.showMemoryUsage, - footer: { - hideCWD: options.hideCWD, - hideSandboxStatus: options.hideSandboxStatus, - hideModelInfo: options.hideModelInfo, - }, - }, - }, - }) as never; - -const renderWithWidth = ( - width: number, - uiState: UIState, - settings: LoadedSettings = createDefaultSettings(), -) => { - useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - return render( - - - - -