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();
+};
+
+describe('', () => {
+ 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 = ({
promptTokenCount,
nightly,
vimMode,
-}) => (
-
-
- {debugMode && }
- {vimMode && [{vimMode}] }
- {nightly ? (
-
-
- {shortenPath(tildeifyPath(targetDir), 70)}
- {branchName && ({branchName}*)}
-
-
- ) : (
-
- {shortenPath(tildeifyPath(targetDir), 70)}
- {branchName && ({branchName}*)}
-
- )}
- {debugMode && (
-
- {' ' + (debugMessage || '--debug')}
-
- )}
-
+}) => {
+ const { columns: terminalWidth } = useTerminalSize();
- {/* 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 (
- {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
-
- {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
-
- ) : process.env.SANDBOX === 'sandbox-exec' ? (
-
- macOS Seatbelt{' '}
- ({process.env.SEATBELT_PROFILE})
-
- ) : (
-
- no sandbox (see /docs)
-
- )}
-
+
+ {debugMode && }
+ {vimMode && [{vimMode}] }
+ {nightly ? (
+
+
+ {displayPath}
+ {branchName && ({branchName}*)}
+
+
+ ) : (
+
+ {displayPath}
+ {branchName && ({branchName}*)}
+
+ )}
+ {debugMode && (
+
+ {' ' + (debugMessage || '--debug')}
+
+ )}
+
- {/* Right Section: Gemini Label and Console Summary */}
-
-
- {' '}
- {model}{' '}
-
-
- {corgiMode && (
-
- |
- ▼
- (´
- ᴥ
- `)
- ▼
+ {/* Middle Section: Centered Sandbox Info */}
+
+ {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
+
+ {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
+
+ ) : process.env.SANDBOX === 'sandbox-exec' ? (
+
+ macOS Seatbelt{' '}
+ ({process.env.SEATBELT_PROFILE})
+
+ ) : (
+
+ no sandbox (see /docs)
+
+ )}
+
+
+ {/* Right Section: Gemini Label and Console Summary */}
+
+
+ {isNarrow ? '' : ' '}
+ {model}{' '}
+
- )}
- {!showErrorDetails && errorCount > 0 && (
-
- |
-
-
- )}
- {showMemoryUsage && }
+ {corgiMode && (
+
+ |
+ ▼
+ (´
+ ᴥ
+ `)
+ ▼
+
+ )}
+ {!showErrorDetails && errorCount > 0 && (
+
+ |
+
+
+ )}
+ {showMemoryUsage && }
+
-
-);
+ );
+};
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('', () => {
+ beforeEach(() => {});
+
+ it('renders the long logo on a wide terminal', () => {
+ vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
+ columns: 120,
+ rows: 20,
+ });
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain(longAsciiLogo);
+ });
+
+ it('renders custom ASCII art when provided', () => {
+ const customArt = 'CUSTOM ART';
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain(customArt);
+ });
+
+ it('displays the version number when nightly is true', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('v1.0.0');
+ });
+
+ it('does not display the version number when nightly is false', () => {
+ const { lastFrame } = render();
+ 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 = ({
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 = ({
)}
{nightly && (
-
+ {Colors.GradientColors ? (
+
+ v{version}
+
+ ) : (
v{version}
-
+ )}
)}
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 = ({
{completion.showSuggestions && (
-
+
= ({
)}
{reverseSearchActive && (
-
+
({
@@ -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(
@@ -223,4 +232,65 @@ describe('', () => {
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(
+ Right}
+ />,
+ 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(
+ Right}
+ />,
+ 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(
+ ,
+ StreamingState.Responding,
+ 80,
+ );
+ expect(lastFrame()?.includes('\n')).toBe(false);
+ });
+
+ it('should use narrow layout at 79 columns', () => {
+ const { lastFrame } = renderWithContext(
+ ,
+ 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 = ({
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 = ({
const primaryText = thought?.subject || currentLoadingPhrase;
+ const cancelAndTimerContent =
+ streamingState !== StreamingState.WaitingForConfirmation
+ ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
+ : null;
+
return (
-
+
{/* Main loading line */}
-
-
-
+
+
+
+
+
+ {primaryText && (
+ {primaryText}
+ )}
+ {!isNarrow && cancelAndTimerContent && (
+ {cancelAndTimerContent}
+ )}
- {primaryText && {primaryText}}
-
- {streamingState === StreamingState.WaitingForConfirmation
- ? ''
- : ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
-
- {/* Spacer */}
- {rightContent && {rightContent}}
+ {!isNarrow && {/* Spacer */}}
+ {!isNarrow && rightContent && {rightContent}}
+ {isNarrow && cancelAndTimerContent && (
+
+ {cancelAndTimerContent}
+
+ )}
+ {isNarrow && rightContent && {rightContent}}
);
};
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 ? (
-
+
{suggestion.description}
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;
+}