import { useLayoutEffect } from "react"; import { t } from "../../../i18n"; import { useThemeColors } from "../../../theme/theme-context"; import { Box, Text, TextAttributes } from "../../../ui"; import { Button, Spinner } from "../../ui"; import type { ListScreenState } from "../list/model"; import { CommandBarListBody } from "../list/view"; import { CommandBarMultiSelectBody, isMultiSelectPickerRoute } from "../multi-select-picker"; import { ThemePicker } from "../theme-picker"; import { truncateText } from "../view-model"; import { CommandBarWorkflowBody } from "../workflow/body"; import type { CommandBarConfirmRoute, CommandBarRoute } from "../workflow/types"; import { NATIVE_COMMAND_SURFACE, nativeCommandSurfaceBorder } from "./native-surface"; import { useCommandBarPalette } from "./palette"; import { publishCommandBarPrompt } from "./prompt-binding"; import type { CommandBarPanelProps } from "./types"; const COMMAND_BAR_OVERLAY_Z_INDEX = 2_147_483_646; const COMMAND_BAR_PANEL_Z_INDEX = 2_147_483_647; /** * The root screen keeps the closed prompt's own words. Opening the bar puts the * caret in the same input the header was already showing, so changing the * placeholder underneath it read as the control being swapped out. Nested * screens are a different screen and say so. */ function resolvePromptPlaceholder(listState: ListScreenState): string { if (listState.kind === "root") return t("Search or run a command"); if (listState.title === "Security Description") return t("Search tickers"); return t("Filter"); } export function CommandBarPanel({ bodyHeight, bodySlotKey, committedThemeId, contentPadding, currentRoute, getWorkflowInputRef, hasChromeRow, labelWidth, listBodyHeight, nativeListRows, nativeListScrollRef, nativeOccluderRect, nativePaneChrome, onBack, onConfirmRoute, onFieldFocus, onFieldPickerOpen, onFieldValueChange, onListHoverIndex, onListRowMouseDown, onListScroll, onMoveFieldFocus, onMultiSelectCommit, onMultiSelectSelect, onMultiSelectToggle, onNativeOccluderChange, onSelectFieldRef, onOverlayClose, onQueryChange, onThemeCommit, onThemePreview, onWorkflowActiveTextareaSync, onWorkflowSubmit, panelBounds, queryDisplayWidth, rootGhostSuffix, rootShortcutFeedback, selectedScrollRowIndex, termHeight, termWidth, themePickerActive, themePickerFilter, themePickerRef, trailingWidth, visibleListState, workflowScrollRef, }: CommandBarPanelProps) { const colors = useThemeColors(); const palette = useCommandBarPalette(nativePaneChrome); useLayoutEffect(() => { const scrollBox = nativeListScrollRef.current; if (!scrollBox) return; if (scrollBox.verticalScrollBar) scrollBox.verticalScrollBar.visible = false; if (selectedScrollRowIndex < 0) return; const viewportHeight = Math.max(1, scrollBox.viewport?.height ?? listBodyHeight); if (selectedScrollRowIndex < scrollBox.scrollTop) { scrollBox.scrollTo(selectedScrollRowIndex); } else if (selectedScrollRowIndex >= scrollBox.scrollTop + viewportHeight) { scrollBox.scrollTo(selectedScrollRowIndex - viewportHeight + 1); } }, [listBodyHeight, nativeListScrollRef, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]); // The header prompt is the bar's input while a list screen is showing. A // workflow owns its own fields, so it publishes nothing and the prompt goes // quiet rather than taking focus from them. useLayoutEffect(() => { if (!visibleListState) { publishCommandBarPrompt(null); return; } publishCommandBarPrompt({ screenKey: `${visibleListState.kind}:${visibleListState.title}`, query: visibleListState.query, placeholder: resolvePromptPlaceholder(visibleListState), ghostSuffix: visibleListState.kind === "root" ? rootGhostSuffix : null, onQueryChange, }); }, [onQueryChange, rootGhostSuffix, visibleListState]); useLayoutEffect(() => () => publishCommandBarPrompt(null), []); useLayoutEffect(() => { onNativeOccluderChange?.(nativeOccluderRect); return () => { onNativeOccluderChange?.(null); }; }, [ nativeOccluderRect.height, nativeOccluderRect.width, nativeOccluderRect.x, nativeOccluderRect.y, onNativeOccluderChange, ]); return ( // The click-away overlay starts where the sheet does: the header above it // holds the bar's input, and a click there must reach it, not close the bar. { event.stopPropagation?.(); event.preventDefault?.(); onOverlayClose(); }} > { event.stopPropagation?.(); }} data-gloom-role="command-bar-panel" style={nativePaneChrome ? { // The lower half of the control the header input opens: rounded and // bordered along its three free edges, open where the input sits. border: `1px solid ${nativeCommandSurfaceBorder(colors)}`, borderTopWidth: 0, borderRadius: `0 0 ${NATIVE_COMMAND_SURFACE.radiusPx}px ${NATIVE_COMMAND_SURFACE.radiusPx}px`, boxShadow: NATIVE_COMMAND_SURFACE.shadow, overflow: "hidden", padding: `${NATIVE_COMMAND_SURFACE.paddingYPx}px ${NATIVE_COMMAND_SURFACE.paddingXPx}px`, } : undefined} > {/* The desktop sheet pads itself in CSS; the terminal spends a row. */} {!nativePaneChrome && } {/* Rows stop at the results column, so a selection bar on a wide window does not run on past the text into empty sheet. */} {/* The query itself is typed in the header prompt. This row only exists when it has something to say: the way back from a nested screen, or what a typed prefix resolved to. Its height is reserved in panel/layout.ts, which is why the render is keyed on the same flag. */} {hasChromeRow && ( <> {currentRoute ? ( <>