mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(card): implement Card component with rendering and glyph support
This commit is contained in:
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user