diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 85c3e0f305..f1e87c0f15 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -40,6 +40,7 @@ vi.mock('../utils/persistentState.js', () => ({ vi.mock('../ui/utils/terminalUtils.js', () => ({ isLowColorDepth: vi.fn(() => false), getColorDepth: vi.fn(() => 24), + isITerm2: vi.fn(() => false), })); // Wrapper around ink-testing-library's render that ensures act() is called diff --git a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.test.tsx b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.test.tsx new file mode 100644 index 0000000000..2f4b51966e --- /dev/null +++ b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.test.tsx @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { HalfLinePaddedBox } from './HalfLinePaddedBox.js'; +import { Text } from 'ink'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isITerm2 } from '../../utils/terminalUtils.js'; + +describe('', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders standard background and blocks when not iTerm2', async () => { + vi.mocked(isITerm2).mockReturnValue(false); + + const { lastFrame, unmount } = renderWithProviders( + + Content + , + { width: 10 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + + unmount(); + }); + + it('renders iTerm2-specific blocks when iTerm2 is detected', async () => { + vi.mocked(isITerm2).mockReturnValue(true); + + const { lastFrame, unmount } = renderWithProviders( + + Content + , + { width: 10 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + + unmount(); + }); + + it('renders nothing when useBackgroundColor is false', async () => { + const { lastFrame, unmount } = renderWithProviders( + + Content + , + { width: 10 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx index 8c4a089bd0..0978c6d736 100644 --- a/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx +++ b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx @@ -13,7 +13,7 @@ import { resolveColor, getSafeLowColorBackground, } from '../../themes/color-utils.js'; -import { isLowColorDepth } from '../../utils/terminalUtils.js'; +import { isLowColorDepth, isITerm2 } from '../../utils/terminalUtils.js'; export interface HalfLinePaddedBoxProps { /** @@ -77,6 +77,35 @@ const HalfLinePaddedBoxInternal: React.FC = ({ return <>{children}; } + const isITerm = isITerm2(); + + if (isITerm) { + return ( + + + {'▄'.repeat(terminalWidth)} + + + {children} + + + {'▀'.repeat(terminalWidth)} + + + ); + } + return ( > renders iTerm2-specific blocks when iTerm2 is detected 1`] = ` +"▄▄▄▄▄▄▄▄▄▄ +Content +▀▀▀▀▀▀▀▀▀▀" +`; + +exports[` > renders nothing when useBackgroundColor is false 1`] = `"Content"`; + +exports[` > renders standard background and blocks when not iTerm2 1`] = ` +"▀▀▀▀▀▀▀▀▀▀ +Content +▄▄▄▄▄▄▄▄▄▄" +`; diff --git a/packages/cli/src/ui/utils/terminalUtils.test.ts b/packages/cli/src/ui/utils/terminalUtils.test.ts new file mode 100644 index 0000000000..70b2a08f17 --- /dev/null +++ b/packages/cli/src/ui/utils/terminalUtils.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isITerm2, resetITerm2Cache } from './terminalUtils.js'; + +describe('terminalUtils', () => { + beforeEach(() => { + vi.stubEnv('TERM_PROGRAM', ''); + vi.stubEnv('ITERM_SESSION_ID', ''); + resetITerm2Cache(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should detect iTerm2 via TERM_PROGRAM', () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + expect(isITerm2()).toBe(true); + }); + + it('should detect iTerm2 via ITERM_SESSION_ID', () => { + vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...'); + expect(isITerm2()).toBe(true); + }); + + it('should return false if not iTerm2', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(isITerm2()).toBe(false); + }); + + it('should cache the result', () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + expect(isITerm2()).toBe(true); + + // Change env but should still be true due to cache + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(isITerm2()).toBe(true); + + resetITerm2Cache(); + expect(isITerm2()).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/utils/terminalUtils.ts b/packages/cli/src/ui/utils/terminalUtils.ts index b1506c2817..5c03198f71 100644 --- a/packages/cli/src/ui/utils/terminalUtils.ts +++ b/packages/cli/src/ui/utils/terminalUtils.ts @@ -20,3 +20,28 @@ export function getColorDepth(): number { export function isLowColorDepth(): boolean { return getColorDepth() < 24; } + +let cachedIsITerm2: boolean | undefined; + +/** + * Returns true if the current terminal is iTerm2. + */ +export function isITerm2(): boolean { + if (cachedIsITerm2 !== undefined) { + return cachedIsITerm2; + } + + cachedIsITerm2 = + process.env['TERM_PROGRAM'] === 'iTerm.app' || + !!process.env['ITERM_SESSION_ID']; + + return cachedIsITerm2; +} + +/** + * Resets the cached iTerm2 detection value. + * Primarily used for testing. + */ +export function resetITerm2Cache(): void { + cachedIsITerm2 = undefined; +}