From 0144b082318a8f5f2378d1a7396850670f754888 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Thu, 8 Jan 2026 17:00:33 -0800 Subject: [PATCH] feat(cli): add ui.compact setting and half-sized logos --- docs/get-started/configuration.md | 4 + packages/cli/src/config/settingsSchema.ts | 9 +++ packages/cli/src/ui/components/AsciiArt.ts | 21 +++++ .../cli/src/ui/components/Header.test.tsx | 80 +++++++++++++++---- packages/cli/src/ui/components/Header.tsx | 51 ++++++++++-- schemas/settings.schema.json | 7 ++ 6 files changed, 153 insertions(+), 19 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 1777d22f5f..da0b4c9171 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -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 diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4dea5d0a99..3ae9806340 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index e1cb5e058b..b004fad479 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -37,6 +37,27 @@ export const tinyAsciiLogo = ` ░░░ ░░░░░░░░░ `; +export const shortAsciiLogoCompact = ` + ▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘ +▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█ +▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█ + ▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘ +`; + +export const longAsciiLogoCompact = ` +▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ + ▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ + ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ +▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ +`; + +export const tinyAsciiLogoCompact = ` +▝▜▄ ▟▛▀▀█▖ + ▝▜▄▐█ + ▗▟▀ ▝█▖ ▜█▘ +▝▀ ▝▀▀▀▀ +`; + export const shortAsciiLogoIde = ` ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░ diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 5200db17d4..0c4c76c51b 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -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('
', () => { columns: 120, rows: 20, }); - render(
); + renderWithProviders(
); expect(Text).toHaveBeenCalledWith( expect.objectContaining({ children: longAsciiLogo, @@ -65,7 +72,7 @@ describe('
', () => { }); vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode'); - render(
); + renderWithProviders(
); expect(Text).toHaveBeenCalledWith( expect.objectContaining({ children: longAsciiLogoIde, @@ -76,7 +83,7 @@ describe('
', () => { it('renders custom ASCII art when provided', () => { const customArt = 'CUSTOM ART'; - render( + renderWithProviders(
, ); expect(Text).toHaveBeenCalledWith( @@ -90,7 +97,7 @@ describe('
', () => { it('renders custom ASCII art as is when running in an IDE', () => { const customArt = 'CUSTOM ART'; vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode'); - render( + renderWithProviders(
, ); expect(Text).toHaveBeenCalledWith( @@ -102,13 +109,13 @@ describe('
', () => { }); it('displays the version number when nightly is true', () => { - render(
); + renderWithProviders(
); 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(
); + renderWithProviders(
); expect(Text).not.toHaveBeenCalledWith( expect.objectContaining({ children: 'v1.0.0', @@ -117,6 +124,45 @@ describe('
', () => { ); }); + it('renders the compact logo when ui.compact is true', () => { + vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ + columns: 120, + rows: 20, + }); + renderWithProviders(
, { + 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( +
, + { + 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('
', () => { }, }); const Gradient = await import('ink-gradient'); - render(
); + renderWithProviders(
); 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('
', () => { ui: { gradient: [singleColor] }, } as typeof semanticColors.theme); const Gradient = await import('ink-gradient'); - render(
); + renderWithProviders(
); 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('
', () => { ui: { gradient: gradientColors }, } as typeof semanticColors.theme); const Gradient = await import('ink-gradient'); - render(
); + renderWithProviders(
); expect(Gradient.default).toHaveBeenCalledWith( expect.objectContaining({ colors: gradientColors, diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 52fd0175c5..f8dfce335e 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -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 = ({ 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 ( + + {title} + {nightly && ( + + v{version} + + )} + + ); + } + return (