Skip to main content
Glama
Terminal.tsx6 kB
import { type FC, type HTMLAttributes, type KeyboardEvent, useMemo, useState, } from 'react'; import { useIntlayer } from 'react-intlayer'; import { cn } from '../../utils/cn'; import { Container } from '../Container'; import { Input } from '../Input'; // ANSI color code mappings to CSS colors const ANSI_COLORS: Record<string, { light: string; dark: string }> = { '\x1b[0m': { light: '', dark: '' }, // RESET '\x1b[90m': { light: 'text-gray-500', dark: 'text-gray-400' }, // GREY '\x1b[38;5;239m': { light: 'text-gray-600', dark: 'text-gray-500' }, // GREY_DARK '\x1b[38;5;252m': { light: 'text-gray-300', dark: 'text-gray-300' }, // GREY_LIGHT '\x1b[34m': { light: 'text-blue-600', dark: 'text-blue-400' }, // BLUE '\x1b[31m': { light: 'text-red-600', dark: 'text-red-400' }, // RED '\x1b[32m': { light: 'text-green-600', dark: 'text-green-400' }, // GREEN '\x1b[38;5;226m': { light: 'text-yellow-500', dark: 'text-yellow-300' }, // YELLOW '\x1b[35m': { light: 'text-purple-600', dark: 'text-purple-400' }, // MAGENTA '\x1b[38;5;3m': { light: 'text-amber-600', dark: 'text-amber-300' }, // BEIGE '\x1b[38;5;208m': { light: 'text-orange-600', dark: 'text-orange-400' }, // ORANGE '\x1b[36m': { light: 'text-cyan-600', dark: 'text-cyan-400' }, // CYAN '\x1b[37m': { light: 'text-gray-800', dark: 'text-gray-200' }, // WHITE '\x1b[1m': { light: 'font-bold', dark: 'font-bold' }, // BOLD }; interface AnsiSegment { text: string; color?: string; isBold?: boolean; } const parseAnsiCodes = (text: string, isDarkMode: boolean): AnsiSegment[] => { const segments: AnsiSegment[] = []; // biome-ignore lint/suspicious/noControlCharactersInRegex: we need to parse ANSI codes const ansiRegex = /(\x1b\[[0-9;]*m)/g; const parts = text.split(ansiRegex); let currentColor: string | undefined; let isBold = false; for (const part of parts) { if (ansiRegex.test(part)) { // This is an ANSI code const colorMapping = ANSI_COLORS[part]; if (colorMapping) { if (part === '\x1b[0m') { // RESET currentColor = undefined; isBold = false; } else if (part === '\x1b[1m') { // BOLD isBold = true; } else { currentColor = isDarkMode ? colorMapping.dark : colorMapping.light; } } } else if (part) { // This is actual text segments.push({ text: part, color: currentColor, isBold, }); } } return segments; }; export type TerminalProps = { children: string; isDarkMode?: boolean; title?: string; onClose?: () => void; onSubmit?: (value: string) => void; } & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onSubmit'>; export const Terminal: FC<TerminalProps> = ({ className, children, isDarkMode = false, title = 'bash', onClose, onSubmit, ...props }) => { const content = useIntlayer('terminal'); const lines = useMemo(() => children.split('\n'), [children]); const [inputValue, setInputValue] = useState(''); const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter' && inputValue.trim()) { onSubmit?.(inputValue); setInputValue(''); } }; // Explicitly type the container props to avoid type conflicts const containerProps = props as Omit< HTMLAttributes<HTMLDivElement>, 'children' | 'onSubmit' >; return ( <Container roundedSize="2xl" className={cn( 'flex min-w-0 max-w-full flex-col overflow-hidden font-mono', className )} {...containerProps} > {/* Tab bar */} <div className="flex w-full flex-row items-center justify-start gap-1 bg-neutral-200 text-neutral text-xs dark:bg-neutral-950"> <div className="mx-2 flex items-center justify-start gap-2 p-1"> <div className="size-3 rounded-full bg-red-500" /> <div className="size-3 rounded-full bg-yellow-500" /> <div className="size-3 rounded-full bg-green-500" /> </div> <div className="flex size-full overflow-y-auto"> <div className="flex h-8 min-w-20 items-center justify-between gap-2 bg-card px-3 py-1"> <span>{title}</span> {onClose && ( <button type="button" onClick={onClose} className="text-neutral transition-colors hover:text-text" aria-label={content.closeTab.value} > × </button> )} </div> </div> </div> {/* Terminal content - hide scrollbar */} <pre className="min-w-0 max-w-full overflow-x-auto overflow-y-auto border-neutral/30 border-b p-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <code> {lines.map((line, lineIndex) => { const segments = parseAnsiCodes(line, isDarkMode); return ( <span className="line block w-full" key={`line-${lineIndex}`}> {segments.length === 0 ? '\n' : segments.map((segment, segIndex) => ( <span key={`seg-${lineIndex}-${segIndex}`} className={cn(segment.color, { 'font-bold': segment.isBold, })} > {segment.text} </span> ))} </span> ); })} </code> </pre> {/* Input area */} <Container className="p-2"> <span className="text-neutral">~/Desktop/MyApp</span> <Input className="m-0.5 w-full" variant="invisible" value={inputValue} aria-label={content.terminalInput.value} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} /> </Container> </Container> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server