feat(cli): add ui.compact setting and half-sized logos

This commit is contained in:
jacob314
2026-01-08 17:00:33 -08:00
parent 5e59eeea3f
commit 0144b08231
6 changed files with 153 additions and 19 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
@@ -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: false,
default: false,
description: 'Enable a more compact UI layout.',
showInDialog: true,
},
incrementalRendering: {
type: 'boolean',
label: 'Incremental Rendering',
@@ -37,6 +37,27 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoCompact = `
`;
export const longAsciiLogoCompact = `
`;
export const tinyAsciiLogoCompact = `
`;
export const shortAsciiLogoIde = `
+66 -14
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,7 +56,7 @@ 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,
@@ -65,7 +72,7 @@ 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,
@@ -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,
}),
undefined,
);
});
it('renders the version to the right 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="row"
});
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,
+46 -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,25 +35,62 @@ 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;
}
const artWidth = getAsciiArtWidth(displayTitle);
const title = useSnowfall(displayTitle);
if (compact) {
return (
<Box
alignItems="flex-end"
flexDirection="row"
flexShrink={0}
marginBottom={1}
>
<ThemedGradient>{title}</ThemedGradient>
{nightly && (
<Box paddingLeft={2}>
<ThemedGradient>v{version}</ThemedGradient>
</Box>
)}
</Box>
);
}
return (
<Box
alignItems="flex-start"
+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.",