feat(card): implement Card component with rendering and glyph support

This commit is contained in:
Mark McLaughlin
2026-02-04 22:29:26 -08:00
parent 70c9975296
commit 0a385dbf97
2 changed files with 216 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { Card } from './Card.js';
import { Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('Card', () => {
it.each([
{
variant: 'warning',
title: 'Gemini CLI update available',
suffix: '0.26.0 → 0.27.0',
prefix: true,
body: 'Installed via Homebrew. Please update with "brew upgrade gemini-cli".',
},
{
variant: 'information',
title: 'Delegate to agent',
suffix: "Delegating to agent 'cli_help'",
prefix: true,
body: '🤖💭 Execution limit reached (ERROR_NO_COMPLETE_TASK_CALL). Attempting one final recovery turn with a grace period.',
},
{
variant: 'error',
title: 'Error',
suffix: '429 You exceeded your current quota',
prefix: true,
body: 'Go to https://aistudio.google.com/apikey to upgrade your quota tier, or submit a quota increase request in https://ai.google.dev/gemini-api/docs/rate-limits',
},
{
variant: 'confirmation',
title: 'Shell',
suffix: 'node -v && which gemini',
prefix: true,
body: "ls /usr/local/bin | grep 'xattr'",
},
{
variant: 'success',
title: 'ReadFolder',
suffix: '/usr/local/bin',
prefix: true,
body: 'Listed 39 item(s).',
},
])(
'renders a $variant card with prefix=$prefix',
({ variant, title, suffix, prefix, body }) => {
const { lastFrame } = render(
<Card
variant={variant}
title={title}
suffix={suffix}
prefix={prefix}
width={80}
>
<Text>{body}</Text>
</Card>,
);
const output = lastFrame();
// eslint-disable-next-line no-console
console.log(output);
expect(output).toMatchSnapshot();
},
);
});
export type { CardProps };

View File

@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { TOOL_STATUS } from '../../constants.js';
/**
* Props for the Card component.
*/
export interface CardProps {
/** The main title of the card. */
title: string;
/** Optional text to display after the title (e.g., version, status). */
suffix?: string;
/** Optional icon or text to display before the title. */
prefix?: boolean;
/** The content to be displayed inside the card. */
children?: React.ReactNode;
/** The styling and intent of the card. */
variant?: 'information' | 'success' | 'warning' | 'error' | 'confirmation';
width?: number | string;
}
const CardDisplay: React.FC<CardProps> = ({
variant = 'information',
title,
prefix = true,
suffix,
children,
}) => {
const getColors = () => {
switch (variant) {
case 'error':
return { border: theme.status.error, text: theme.status.error };
case 'warning':
return { border: theme.status.warning, text: theme.status.warning };
case 'success':
return { border: theme.status.success, text: theme.status.success };
case 'confirmation':
return { border: theme.border.focused, text: theme.text.link };
default:
return { border: theme.border.default, text: theme.text.primary };
}
};
const getGlyph = () => {
switch (variant) {
case 'error':
return TOOL_STATUS.ERROR;
case 'success':
return TOOL_STATUS.SUCCESS;
case 'warning':
return TOOL_STATUS.WARNING;
case 'confirmation':
return TOOL_STATUS.CONFIRMING;
default:
return TOOL_STATUS.INFORMATION;
}
};
const colors = getColors();
const glyph = getGlyph();
return (
<Box flexDirection="column" marginBottom={1}>
<Box width="100%" flexDirection="row">
{/* Top border section */}
<Box
borderStyle="round"
borderBottom={false}
borderRight={false}
paddingLeft={0}
borderColor={colors.border}
/>
{/* Label */}
<Box flexGrow={1}>
<Box
paddingX={1}
flexDirection="row"
gap={1}
justifyContent="flex-start"
>
<Box>{prefix && <Text color={colors.text}>{glyph}</Text>}</Box>
<Text bold color={colors.text}>
{title}
</Text>
{suffix && (
<Text color={colors.text} dimColor wrap="truncate-end">
{suffix}
</Text>
)}
</Box>
{/* Top border after text */}
<Box
borderStyle="single"
flexGrow={1}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={colors.border}
/>
</Box>
{/* Right border */}
<Box
borderStyle="round"
borderBottom={false}
borderLeft={false}
paddingRight={1}
borderColor={colors.border}
></Box>
</Box>
{/* Content area */}
<Box
borderStyle="round"
borderTop={false}
flexDirection="column"
paddingX={1}
paddingY={0}
borderColor={colors.border}
>
{children}
</Box>
</Box>
);
};
export const Card: React.FC<CardProps> = ({
title,
prefix,
suffix,
children,
variant,
}) => (
<CardDisplay title={title} prefix={prefix} suffix={suffix} variant={variant}>
{children}
</CardDisplay>
);