/** * Terminal Compose Bar * An immersive prompt bar below the terminal with a quick-snippet strip, * user-resizable height, and terminal-matched chrome. */ import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useComposeBarHeight } from '../../application/state/useComposeBarHeight'; import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets'; import { useI18n } from '../../application/i18n/I18nProvider'; import { resolveSnippetCommand } from '../SnippetExecutionProvider'; import { Snippet } from '../../types'; import { cn } from '../../lib/utils'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { buildSnippetIdKey, filterComposeBarSnippets, mergeComposeBarSnippetMap, resolveComposeBarDefaultSeedIds, } from './composeBarHelpers'; const SNIPPET_STRIP_HEIGHT = 30; const RESIZE_HANDLE_HEIGHT = 6; type ComposeBarTheme = { resolvedBg: string; resolvedFg: string; borderColor: string; mutedFg: string; hoverBg: string; chipBg: string; chipHoverBg: string; }; function buildTheme(themeColors?: { background: string; foreground: string }): ComposeBarTheme { const bg = themeColors?.background ?? '#0a0a0a'; const fg = themeColors?.foreground ?? '#d4d4d4'; const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')'; const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')'; return { resolvedBg, resolvedFg, borderColor: `color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`, mutedFg: `color-mix(in srgb, ${resolvedFg} 55%, ${resolvedBg} 45%)`, hoverBg: `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`, chipBg: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`, chipHoverBg: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`, }; } interface ComposeBarSnippetChipProps { snippet: Snippet; theme: ComposeBarTheme; onActivate: (snippet: Snippet, sendImmediately: boolean) => void; onUnpin: (id: string) => void; unpinLabel: string; clickHint: string; } const ComposeBarSnippetChip = memo(function ComposeBarSnippetChip({ snippet, theme, onActivate, onUnpin, unpinLabel, clickHint, }: ComposeBarSnippetChipProps) { const commandPreview = snippet.command.split('\n')[0]; return (
{ e.currentTarget.style.backgroundColor = theme.chipHoverBg; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = theme.chipBg; }} >

{snippet.label}

{commandPreview}

{clickHint}

); }); interface ComposeBarSnippetManagePopoverProps { snippets: Snippet[]; pinnedCount: number; theme: ComposeBarTheme; isPinned: (id: string) => boolean; onTogglePin: (id: string) => void; manageLabel: string; searchPlaceholder: string; noSnippetsLabel: string; noMatchingLabel: string; pinnedCountLabel: string; } const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopover({ snippets, pinnedCount, theme, isPinned, onTogglePin, manageLabel, searchPlaceholder, noSnippetsLabel, noMatchingLabel, pinnedCountLabel, }: ComposeBarSnippetManagePopoverProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const filteredSnippets = useMemo( () => filterComposeBarSnippets(snippets, search), [snippets, search], ); return ( { setOpen(next); if (!next) setSearch(''); }} >

{manageLabel}

setSearch(e.target.value)} placeholder={searchPlaceholder} className="flex-1 min-w-0 bg-transparent text-[11px] font-mono outline-none placeholder:opacity-60" style={{ color: theme.resolvedFg }} />
{snippets.length === 0 ? (

{noSnippetsLabel}

) : filteredSnippets.length === 0 ? (

{noMatchingLabel}

) : ( filteredSnippets.map((snippet) => { const pinned = isPinned(snippet.id); return ( ); }) )}
{pinnedCount > 0 && (
{pinnedCountLabel}
)}
); }); export interface TerminalComposeBarProps { onSend: (text: string) => void; onClose: () => void; onSnippetClick?: (snippet: Snippet) => void; snippets?: Snippet[]; isBroadcastEnabled?: boolean; themeColors?: { background: string; foreground: string; }; } export const TerminalComposeBar: React.FC = ({ onSend, onClose, onSnippetClick, snippets = [], isBroadcastEnabled, themeColors, }) => { const { t } = useI18n(); const textareaRef = useRef(null); const isComposingRef = useRef(false); const resizeCleanupRef = useRef<(() => void) | null>(null); const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight(); const heightRef = useRef(barHeight); const snippetIdKey = useMemo( () => buildSnippetIdKey(snippets.map((snippet) => snippet.id)), [snippets], ); const defaultSeedIds = useMemo( () => resolveComposeBarDefaultSeedIds(snippets), [snippets], ); const { pinnedIds, unpin, toggle, isPinned } = useComposeBarPinnedSnippets( snippetIdKey, defaultSeedIds, ); heightRef.current = barHeight; const theme = useMemo(() => buildTheme(themeColors), [themeColors]); const snippetsById = useMemo( () => mergeComposeBarSnippetMap(snippets), [snippets], ); const pinnedSnippets = useMemo( () => pinnedIds .map((id) => snippetsById.get(id)) .filter((snippet): snippet is Snippet => Boolean(snippet)), [pinnedIds, snippetsById], ); const clickHint = t('terminal.composeBar.snippetClickHint'); useEffect(() => { const timer = setTimeout(() => textareaRef.current?.focus(), 50); return () => clearTimeout(timer); }, []); useEffect(() => () => { resizeCleanupRef.current?.(); }, []); const handleSend = useCallback(() => { const el = textareaRef.current; if (!el) return; const text = el.value; if (!text) return; onSend(text); el.value = ''; el.focus(); }, [onSend]); const insertCommand = useCallback((command: string) => { const el = textareaRef.current; if (!el) return; const prefix = el.value && !el.value.endsWith('\n') ? '\n' : ''; el.value = el.value ? `${el.value}${prefix}${command}` : command; el.focus(); }, []); const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => { if (sendImmediately) { if (onSnippetClick) { onSnippetClick(snippet); } else { const command = await resolveSnippetCommand(snippet); if (command !== null) onSend(command); } return; } const command = await resolveSnippetCommand(snippet); if (command === null) return; insertCommand(command); }, [insertCommand, onSend, onSnippetClick]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) { e.preventDefault(); handleSend(); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } }, [handleSend, onClose]); const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); resizeCleanupRef.current?.(); const startY = e.clientY; const startHeight = heightRef.current; document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; const onMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientY - startY; setBarHeight(startHeight - delta); }; const cleanup = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); resizeCleanupRef.current = null; }; const onUp = () => { persistBarHeight(heightRef.current); cleanup(); }; resizeCleanupRef.current = cleanup; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [persistBarHeight, setBarHeight]); return (
{pinnedSnippets.map((snippet) => ( ))}
{isBroadcastEnabled && (
{t('terminal.composeBar.broadcasting')}
)}