Compare commits

...

3 Commits

Author SHA1 Message Date
jacob314 0f5bcfad1e Compact mode. 2026-01-09 09:19:52 -08:00
jacob314 0144b08231 feat(cli): add ui.compact setting and half-sized logos 2026-01-08 17:00:33 -08:00
Tianqi Zhang 5e59eeea3f Fix settings validation to properly respect additionalProperties: false 2026-01-08 16:47:46 -08:00
12 changed files with 228 additions and 72 deletions
+4
View File
@@ -243,6 +243,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.compact`** (boolean):
- **Description:** Enable a more compact UI layout.
- **Default:** `false`
- **`ui.incrementalRendering`** (boolean):
- **Description:** Enable incremental rendering for the UI. This option will
reduce flickering but may cause rendering artifacts. Only supported when
@@ -54,9 +54,10 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
}
shape[key] = propSchema;
}
schema = z.object(shape).passthrough();
// Don't apply passthrough() here - we'll determine the behavior below
schema = z.object(shape);
} else {
schema = z.object({}).passthrough();
schema = z.object({});
}
if (def.additionalProperties === false) {
@@ -65,6 +66,9 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
schema = schema.catchall(
buildZodSchemaFromJsonSchema(def.additionalProperties),
);
} else {
// Default to passthrough when additionalProperties is not explicitly false
schema = schema.passthrough();
}
return schema;
@@ -524,6 +524,15 @@ const SETTINGS_SCHEMA = {
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
},
compact: {
type: 'boolean',
label: 'Compact UI',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Enable a more compact UI layout.',
showInDialog: true,
},
incrementalRendering: {
type: 'boolean',
label: 'Incremental Rendering',
@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
@@ -200,4 +203,34 @@ describe('<AppHeader />', () => {
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
});
it('should render in compact mode', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: '',
warningText: '',
},
nightly: true,
};
const { lastFrame, unmount } = renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
settings: createMockSettings({
ui: {
compact: true,
},
}),
},
);
expect(lastFrame()).toContain('Tips for getting started:');
expect(lastFrame()).toContain('v1.0.0');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
+28 -14
View File
@@ -21,25 +21,39 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
const compact = settings.merged.ui?.compact;
const { bannerText } = useBanner(bannerData, config);
return (
const showHeader = !(
settings.merged.ui?.hideBanner || config.getScreenReader()
);
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
const headerContent = showHeader && (
<Box flexDirection="column">
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={mainAreaWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
</>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={mainAreaWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
</Box>
);
const tipsContent = showTips && <Tips config={config} />;
return (
<Box
flexDirection={compact ? 'row' : 'column'}
marginTop={compact ? 1 : 0}
marginBottom={0}
>
{headerContent}
{tipsContent && (
<Box marginLeft={compact && showHeader ? 4 : 0}>{tipsContent}</Box>
)}
</Box>
);
@@ -37,6 +37,27 @@ export const tinyAsciiLogo = `
░░░ ░░░░░░░░░
`;
export const shortAsciiLogoCompact = `
▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘
▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█
▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█
▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘
`;
export const longAsciiLogoCompact = `
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
`;
export const tinyAsciiLogoCompact = `
▝▜▄ ▟▛▀▀█▖
▝▜▄▐█
▗▟▀ ▝█▖ ▜█▘
▝▀ ▝▀▀▀▀
`;
export const shortAsciiLogoIde = `
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
+68 -16
View File
@@ -4,11 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
renderWithProviders,
createMockSettings,
} from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
import {
longAsciiLogo,
longAsciiLogoIde,
longAsciiLogoCompact,
} from './AsciiArt.js';
import * as semanticColors from '../semantic-colors.js';
import * as terminalSetup from '../utils/terminalSetup.js';
import { Text } from 'ink';
@@ -49,10 +56,10 @@ describe('<Header />', () => {
columns: 120,
rows: 20,
});
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogo,
children: longAsciiLogo.trim(),
}),
undefined,
);
@@ -65,10 +72,10 @@ describe('<Header />', () => {
});
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogoIde,
children: longAsciiLogoIde.trim(),
}),
undefined,
);
@@ -76,7 +83,7 @@ describe('<Header />', () => {
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
render(
renderWithProviders(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
@@ -90,7 +97,7 @@ describe('<Header />', () => {
it('renders custom ASCII art as is when running in an IDE', () => {
const customArt = 'CUSTOM ART';
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
render(
renderWithProviders(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
@@ -102,13 +109,13 @@ describe('<Header />', () => {
});
it('displays the version number when nightly is true', () => {
render(<Header version="1.0.0" nightly={true} />);
renderWithProviders(<Header version="1.0.0" nightly={true} />);
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Text).not.toHaveBeenCalledWith(
expect.objectContaining({
children: 'v1.0.0',
@@ -117,6 +124,45 @@ describe('<Header />', () => {
);
});
it('renders the compact logo when ui.compact is true', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
renderWithProviders(<Header version="1.0.0" nightly={false} />, {
settings: createMockSettings({
ui: {
compact: true,
},
}),
});
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogoCompact.trim(),
}),
undefined,
);
});
it('renders the version under the logo in compact mode when nightly is true', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = renderWithProviders(
<Header version="1.0.0" nightly={true} />,
{
settings: createMockSettings({
ui: {
compact: true,
},
}),
},
);
expect(lastFrame()).toContain('v1.0.0');
// In compact mode, logo and version are in the same Box with flexDirection="column"
});
it('renders with no gradient when theme.ui.gradient is undefined', async () => {
vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
text: {
@@ -147,10 +193,13 @@ describe('<Header />', () => {
},
});
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[0][0]).toHaveProperty('color', '#123456');
expect(textCalls[textCalls.length - 1][0]).toHaveProperty(
'color',
'#123456',
);
});
it('renders with a single color when theme.ui.gradient has one color', async () => {
@@ -159,11 +208,14 @@ describe('<Header />', () => {
ui: { gradient: [singleColor] },
} as typeof semanticColors.theme);
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
expect(textCalls.length).toBe(1);
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
expect(textCalls.length).toBeGreaterThanOrEqual(1);
expect(textCalls[textCalls.length - 1][0]).toHaveProperty(
'color',
singleColor,
);
});
it('renders with a gradient when theme.ui.gradient has two or more colors', async () => {
@@ -172,7 +224,7 @@ describe('<Header />', () => {
ui: { gradient: gradientColors },
} as typeof semanticColors.theme);
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
renderWithProviders(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).toHaveBeenCalledWith(
expect.objectContaining({
colors: gradientColors,
+30 -5
View File
@@ -14,11 +14,15 @@ import {
shortAsciiLogoIde,
longAsciiLogoIde,
tinyAsciiLogoIde,
shortAsciiLogoCompact,
longAsciiLogoCompact,
tinyAsciiLogoCompact,
} from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
import { useSnowfall } from '../hooks/useSnowfall.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
@@ -31,22 +35,43 @@ export const Header: React.FC<HeaderProps> = ({
version,
nightly,
}) => {
const { merged: settings } = useSettings();
const compact = settings.ui?.compact;
const { columns: terminalWidth } = useTerminalSize();
const isIde = getTerminalProgram();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
const longLogo = compact
? longAsciiLogoCompact
: isIde
? longAsciiLogoIde
: longAsciiLogo;
const shortLogo = compact
? shortAsciiLogoCompact
: isIde
? shortAsciiLogoIde
: shortAsciiLogo;
const tinyLogo = compact
? tinyAsciiLogoCompact
: isIde
? tinyAsciiLogoIde
: tinyAsciiLogo;
const widthOfLongLogo = getAsciiArtWidth(longLogo);
const widthOfShortLogo = getAsciiArtWidth(shortLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
displayTitle = longLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
displayTitle = shortLogo;
} else {
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
displayTitle = tinyLogo;
}
displayTitle = displayTitle.trim();
const artWidth = getAsciiArtWidth(displayTitle);
const title = useSnowfall(displayTitle);
+1 -1
View File
@@ -17,7 +17,7 @@ export const Tips: React.FC<TipsProps> = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
return (
<Box flexDirection="column">
<Text color={theme.text.primary}>Tips for getting started:</Text>
<Text color={theme.text.accent}>Tips for getting started:</Text>
<Text color={theme.text.primary}>
1. Ask questions, edit files, or run commands.
</Text>
@@ -1,8 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -10,7 +9,6 @@ exports[`AlternateBufferQuittingDisplay > renders with active and pending tool m
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -31,8 +29,7 @@ Tips for getting started:
`;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -40,7 +37,6 @@ exports[`AlternateBufferQuittingDisplay > renders with empty history and no pend
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -49,8 +45,7 @@ Tips for getting started:
`;
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -58,7 +53,6 @@ exports[`AlternateBufferQuittingDisplay > renders with history but no pending it
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -75,8 +69,7 @@ Tips for getting started:
`;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -84,7 +77,6 @@ exports[`AlternateBufferQuittingDisplay > renders with pending items but no hist
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -97,8 +89,7 @@ Tips for getting started:
`;
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -106,7 +97,6 @@ exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -1,8 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -10,7 +9,6 @@ exports[`<AppHeader /> > should not render the banner when no flags are set 1`]
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -19,8 +17,7 @@ Tips for getting started:
`;
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -28,7 +25,6 @@ exports[`<AppHeader /> > should not render the banner when previewFeatures is en
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -37,8 +33,7 @@ Tips for getting started:
`;
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -46,7 +41,6 @@ exports[`<AppHeader /> > should not render the default banner if shown count is
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
@@ -54,9 +48,17 @@ Tips for getting started:
4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
exports[`<AppHeader /> > should render in compact mode 1`] = `
"
███ █████████
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ Tips for getting started:
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ 1. Ask questions, edit files, or run commands.
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ 2. Be specific for the best results.
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ 3. Create GEMINI.md files to customize your interactions with Gemini.
v1.0.0 4. /help for more information."
`;
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -64,7 +66,6 @@ exports[`<AppHeader /> > should render the banner when previewFeatures is disabl
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -76,8 +77,7 @@ Tips for getting started:
`;
exports[`<AppHeader /> > should render the banner with default text 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -85,7 +85,6 @@ exports[`<AppHeader /> > should render the banner with default text 1`] = `
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -97,8 +96,7 @@ Tips for getting started:
`;
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
"
███ █████████
"███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
@@ -106,7 +104,6 @@ exports[`<AppHeader /> > should render the banner with warning text 1`] = `
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+7
View File
@@ -302,6 +302,13 @@
"default": false,
"type": "boolean"
},
"compact": {
"title": "Compact UI",
"description": "Enable a more compact UI layout.",
"markdownDescription": "Enable a more compact UI layout.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"incrementalRendering": {
"title": "Incremental Rendering",
"description": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.",