style: 格式化 packages/@ant/ 下所有文件以通过 biome ci

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-05-01 21:55:51 +08:00
parent c32f26cf21
commit 9ea9859dce
92 changed files with 5903 additions and 5188 deletions

View File

@@ -1,10 +1,10 @@
import React, { Children, isValidElement } from 'react'
import { Text } from '../index.js'
import React, { Children, isValidElement } from 'react';
import { Text } from '../index.js';
type Props = {
/** The items to join with a middot separator */
children: React.ReactNode
}
children: React.ReactNode;
};
/**
* Joins children with a middot separator (" · ") for inline metadata display.
@@ -36,22 +36,20 @@ type Props = {
*/
export function Byline({ children }: Props): React.ReactNode {
// Children.toArray already filters out null, undefined, and booleans
const validChildren = Children.toArray(children)
const validChildren = Children.toArray(children);
if (validChildren.length === 0) {
return null
return null;
}
return (
<>
{validChildren.map((child, index) => (
<React.Fragment
key={isValidElement(child) ? (child.key ?? index) : index}
>
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
{index > 0 && <Text dimColor> · </Text>}
{child}
</React.Fragment>
))}
</>
)
);
}

View File

@@ -6,30 +6,18 @@
* internal theme components.
*/
import React from 'react'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import React from 'react';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
type Props = {
action: string
context: string
fallback: string
description: string
parens?: boolean
bold?: boolean
}
action: string;
context: string;
fallback: string;
description: string;
parens?: boolean;
bold?: boolean;
};
export function ConfigurableShortcutHint({
fallback,
description,
parens,
bold,
}: Props): React.ReactNode {
return (
<KeyboardShortcutHint
shortcut={fallback}
action={description}
parens={parens}
bold={bold}
/>
)
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
}

View File

@@ -1,26 +1,23 @@
import React from 'react'
import {
type ExitState,
useExitOnCtrlCDWithKeybindings,
} from '../hooks/useExitOnCtrlCD.js'
import { Box, Text } from '../index.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { Pane } from './Pane.js'
import React from 'react';
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
import { Box, Text } from '../index.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { Pane } from './Pane.js';
type DialogProps = {
title: React.ReactNode
subtitle?: React.ReactNode
children: React.ReactNode
onCancel: () => void
color?: keyof Theme
hideInputGuide?: boolean
hideBorder?: boolean
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onCancel: () => void;
color?: keyof Theme;
hideInputGuide?: boolean;
hideBorder?: boolean;
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
inputGuide?: (exitState: ExitState) => React.ReactNode
inputGuide?: (exitState: ExitState) => React.ReactNode;
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
@@ -28,8 +25,8 @@ type DialogProps = {
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
* press, delete-forward on ctrl+d with text). Defaults to `true`.
*/
isCancelActive?: boolean
}
isCancelActive?: boolean;
};
export function Dialog({
title,
@@ -42,11 +39,7 @@ export function Dialog({
inputGuide,
isCancelActive = true,
}: DialogProps): React.ReactNode {
const exitState = useExitOnCtrlCDWithKeybindings(
undefined,
undefined,
isCancelActive,
)
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
// Use configurable keybinding for ESC to cancel.
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
@@ -55,21 +48,16 @@ export function Dialog({
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive,
})
});
const defaultInputGuide = exitState.pending ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
)
);
const content = (
<>
@@ -90,11 +78,11 @@ export function Dialog({
</Box>
)}
</>
)
);
if (hideBorder) {
return content
return content;
}
return <Pane color={color}>{content}</Pane>
return <Pane color={color}>{content}</Pane>;
}

View File

@@ -1,33 +1,33 @@
import React from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../core/stringWidth.js'
import { Ansi, Text } from '../index.js'
import type { Theme } from './theme-types.js'
import React from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { stringWidth } from '../core/stringWidth.js';
import { Ansi, Text } from '../index.js';
import type { Theme } from './theme-types.js';
type DividerProps = {
/**
* Width of the divider in characters.
* Defaults to terminal width.
*/
width?: number
width?: number;
/**
* Theme color for the divider.
* If not provided, dimColor is used.
*/
color?: keyof Theme
color?: keyof Theme;
/**
* Character to use for the divider line.
* @default '─'
*/
char?: string
char?: string;
/**
* Padding to subtract from the width (e.g., for indentation).
* @default 0
*/
padding?: number
padding?: number;
/**
* Title shown in the middle of the divider.
@@ -37,8 +37,8 @@ type DividerProps = {
* // ─────────── Title ───────────
* <Divider title="Title" />
*/
title?: string
}
title?: string;
};
/**
* A horizontal divider line.
@@ -63,21 +63,15 @@ type DividerProps = {
* // With centered title
* <Divider title="3 new messages" />
*/
export function Divider({
width,
color,
char = '─',
padding = 0,
title,
}: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize();
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
if (title) {
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
const leftWidth = Math.floor(sideWidth / 2)
const rightWidth = sideWidth - leftWidth
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
const leftWidth = Math.floor(sideWidth / 2);
const rightWidth = sideWidth - leftWidth;
return (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)}{' '}
@@ -86,12 +80,12 @@ export function Divider({
</Text>{' '}
{char.repeat(rightWidth)}
</Text>
)
);
}
return (
<Text color={color} dimColor={!color}>
{char.repeat(effectiveWidth)}
</Text>
)
);
}

View File

@@ -1,72 +1,72 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { clamp } from '../core/layout/geometry.js'
import { Box, Text, useTerminalFocus } from '../index.js'
import { SearchBox } from './SearchBox.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { ListItem } from './ListItem.js'
import { Pane } from './Pane.js'
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { clamp } from '../core/layout/geometry.js';
import { Box, Text, useTerminalFocus } from '../index.js';
import { SearchBox } from './SearchBox.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { ListItem } from './ListItem.js';
import { Pane } from './Pane.js';
type PickerAction<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string
handler: (item: T) => void
}
action: string;
handler: (item: T) => void;
};
type Props<T> = {
title: string
placeholder?: string
initialQuery?: string
items: readonly T[]
getKey: (item: T) => string
title: string;
placeholder?: string;
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode
renderPreview?: (item: T) => React.ReactNode
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right'
visibleCount?: number
previewPosition?: 'bottom' | 'right';
visibleCount?: number;
/**
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
* always match screen direction — ↑ walks visually up regardless.
*/
direction?: 'down' | 'up'
direction?: 'down' | 'up';
/** Caller owns filtering: re-filter on each call and pass new items. */
onQueryChange: (query: string) => void
onQueryChange: (query: string) => void;
/** Enter key. Primary action. */
onSelect: (item: T) => void
onSelect: (item: T) => void;
/**
* Tab key. If provided, Tab no longer aliases Enter — it gets its own
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
*/
onTab?: PickerAction<T>
onTab?: PickerAction<T>;
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>
onShiftTab?: PickerAction<T>;
/**
* Fires when the focused item changes (via arrows or when items reset).
* Useful for async preview loading — keeps I/O out of renderPreview.
*/
onFocus?: (item: T | undefined) => void
onCancel: () => void
onFocus?: (item: T | undefined) => void;
onCancel: () => void;
/** Shown when items is empty. Caller bakes loading/searching state into this. */
emptyMessage?: string | ((query: string) => string)
emptyMessage?: string | ((query: string) => string);
/**
* Status line below the list, e.g. "500+ matches" or "42 matches…".
* Caller decides when to show it — pass undefined to hide.
*/
matchLabel?: string
selectAction?: string
extraHints?: React.ReactNode
}
matchLabel?: string;
selectAction?: string;
extraHints?: React.ReactNode;
};
const DEFAULT_VISIBLE = 8
const DEFAULT_VISIBLE = 8;
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
const CHROME_ROWS = 10
const MIN_VISIBLE = 2
const CHROME_ROWS = 10;
const MIN_VISIBLE = 2;
export function FuzzyPicker<T>({
title,
@@ -90,25 +90,22 @@ export function FuzzyPicker<T>({
selectAction = 'select',
extraHints,
}: Props<T>): React.ReactNode {
const isTerminalFocused = useTerminalFocus()
const { rows, columns } = useTerminalSize()
const [focusedIndex, setFocusedIndex] = useState(0)
const isTerminalFocused = useTerminalFocus();
const { rows, columns } = useTerminalSize();
const [focusedIndex, setFocusedIndex] = useState(0);
// Cap visibleCount so the picker never exceeds the terminal height. When it
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
// by the overflow amount and a previously-drawn line flashes blank.
const visibleCount = Math.max(
MIN_VISIBLE,
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
)
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
// below that. Compact mode drops shift+tab and shortens labels.
const compact = columns < 120
const compact = columns < 120;
const step = (delta: 1 | -1) => {
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1))
}
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
};
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
@@ -120,67 +117,62 @@ export function FuzzyPicker<T>({
onCancel,
initialQuery,
backspaceExitsOnEmpty: false,
})
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? 1 : -1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? 1 : -1);
return;
}
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? -1 : 1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? -1 : 1);
return;
}
if (e.key === 'return') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (selected) onSelect(selected)
return
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (selected) onSelect(selected);
return;
}
if (e.key === 'tab') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (!selected) return
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (!selected) return;
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
if (tabAction) {
tabAction.handler(selected)
tabAction.handler(selected);
} else {
onSelect(selected)
onSelect(selected);
}
}
}
};
useEffect(() => {
onQueryChange(query)
setFocusedIndex(0)
onQueryChange(query);
setFocusedIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
}, [query]);
useEffect(() => {
setFocusedIndex(i => clamp(i, 0, items.length - 1))
}, [items.length])
setFocusedIndex(i => clamp(i, 0, items.length - 1));
}, [items.length]);
const focused = items[focusedIndex]
const focused = items[focusedIndex];
useEffect(() => {
onFocus?.(focused)
onFocus?.(focused);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused])
}, [focused]);
const windowStart = clamp(
focusedIndex - visibleCount + 1,
0,
items.length - visibleCount,
)
const visible = items.slice(windowStart, windowStart + visibleCount)
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
const visible = items.slice(windowStart, windowStart + visibleCount);
const emptyText =
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const searchBox = (
<SearchBox
@@ -190,7 +182,7 @@ export function FuzzyPicker<T>({
isFocused
isTerminalFocused={isTerminalFocused}
/>
)
);
const listBlock = (
<List
@@ -204,25 +196,21 @@ export function FuzzyPicker<T>({
renderItem={renderItem}
emptyText={emptyText}
/>
)
);
const preview =
renderPreview && focused ? (
<Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box>
) : null
) : null;
// Structure must not depend on preview truthiness — when focused goes
// undefined (e.g. delete clears matches), switching row→fragment would
// change both layout AND gap count, bouncing the searchBox below.
const listGroup =
renderPreview && previewPosition === 'right' ? (
<Box
flexDirection="row"
gap={2}
height={visibleCount + (matchLabel ? 1 : 0)}
>
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
@@ -238,18 +226,12 @@ export function FuzzyPicker<T>({
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>
)
);
const inputAbove = direction !== 'up'
const inputAbove = direction !== 'up';
return (
<Pane color="permission">
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text bold color="permission">
{title}
</Text>
@@ -258,42 +240,26 @@ export function FuzzyPicker<T>({
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint
shortcut="↑/↓"
action={compact ? 'nav' : 'navigate'}
/>
<KeyboardShortcutHint
shortcut="Enter"
action={compact ? firstWord(selectAction) : selectAction}
/>
{onTab && (
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
)}
{onShiftTab && !compact && (
<KeyboardShortcutHint
shortcut="shift+tab"
action={onShiftTab.action}
/>
)}
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>
)
);
}
type ListProps<T> = Pick<
Props<T>,
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
> & {
visible: readonly T[]
windowStart: number
total: number
focusedIndex: number
emptyText: string
}
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
visible: readonly T[];
windowStart: number;
total: number;
focusedIndex: number;
emptyText: string;
};
function List<T>({
visible,
@@ -311,15 +277,14 @@ function List<T>({
<Box height={visibleCount} flexShrink={0}>
<Text dimColor>{emptyText}</Text>
</Box>
)
);
}
const rows = visible.map((item, i) => {
const actualIndex = windowStart + i
const isFocused = actualIndex === focusedIndex
const atLowEdge = i === 0 && windowStart > 0
const atHighEdge =
i === visible.length - 1 && windowStart + visibleCount! < total
const actualIndex = windowStart + i;
const isFocused = actualIndex === focusedIndex;
const atLowEdge = i === 0 && windowStart > 0;
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
return (
<ListItem
key={getKey(item)}
@@ -330,21 +295,17 @@ function List<T>({
>
{renderItem(item, isFocused)}
</ListItem>
)
})
);
});
return (
<Box
height={visibleCount}
flexShrink={0}
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
>
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
{rows}
</Box>
)
);
}
function firstWord(s: string): string {
const i = s.indexOf(' ')
return i === -1 ? s : s.slice(0, i)
const i = s.indexOf(' ');
return i === -1 ? s : s.slice(0, i);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import Text from '../components/Text.js'
import React from 'react';
import Text from '../components/Text.js';
type Props = {
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
shortcut: string
shortcut: string;
/** The action the key performs (e.g., "expand", "select", "navigate") */
action: string
action: string;
/** Whether to wrap the hint in parentheses. Default: false */
parens?: boolean
parens?: boolean;
/** Whether to render the shortcut in bold. Default: false */
bold?: boolean
}
bold?: boolean;
};
/**
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
@@ -35,24 +35,19 @@ type Props = {
* </Byline>
* </Text>
*/
export function KeyboardShortcutHint({
shortcut,
action,
parens = false,
bold = false,
}: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
if (parens) {
return (
<Text>
({shortcutText} to {action})
</Text>
)
);
}
return (
<Text>
{shortcutText} to {action}
</Text>
)
);
}

View File

@@ -1,44 +1,44 @@
import figures from 'figures'
import type { ReactNode } from 'react'
import React from 'react'
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
import { Box, Text } from '../index.js'
import figures from 'figures';
import type { ReactNode } from 'react';
import React from 'react';
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
import { Box, Text } from '../index.js';
type ListItemProps = {
/**
* Whether this item is currently focused (keyboard selection).
* Shows the pointer indicator () when true.
*/
isFocused: boolean
isFocused: boolean;
/**
* Whether this item is selected (chosen/checked).
* Shows the checkmark indicator (✓) when true.
* @default false
*/
isSelected?: boolean
isSelected?: boolean;
/**
* The content to display for this item.
*/
children: ReactNode
children: ReactNode;
/**
* Optional description text displayed below the main content.
*/
description?: string
description?: string;
/**
* Show a down arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollDown?: boolean
showScrollDown?: boolean;
/**
* Show an up arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollUp?: boolean
showScrollUp?: boolean;
/**
* Whether to apply automatic styling to the children based on focus/selection state.
@@ -46,21 +46,21 @@ type ListItemProps = {
* - When false: children are rendered as-is, allowing custom styling
* @default true
*/
styled?: boolean
styled?: boolean;
/**
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
* @default false
*/
disabled?: boolean
disabled?: boolean;
/**
* Whether this ListItem should declare the terminal cursor position.
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
* @default true
*/
declareCursor?: boolean
}
declareCursor?: boolean;
};
/**
* A list item component for selection UIs (dropdowns, multi-selects, menus).
@@ -115,46 +115,46 @@ export function ListItem({
// Determine which indicator to show
function renderIndicator(): ReactNode {
if (disabled) {
return <Text> </Text>
return <Text> </Text>;
}
if (isFocused) {
return <Text color="suggestion">{figures.pointer}</Text>
return <Text color="suggestion">{figures.pointer}</Text>;
}
if (showScrollDown) {
return <Text dimColor>{figures.arrowDown}</Text>
return <Text dimColor>{figures.arrowDown}</Text>;
}
if (showScrollUp) {
return <Text dimColor>{figures.arrowUp}</Text>
return <Text dimColor>{figures.arrowUp}</Text>;
}
return <Text> </Text>
return <Text> </Text>;
}
// Determine text color based on state
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
if (disabled) {
return 'inactive'
return 'inactive';
}
if (!styled) {
return undefined
return undefined;
}
if (isSelected) {
return 'success'
return 'success';
}
if (isFocused) {
return 'suggestion'
return 'suggestion';
}
return undefined
return undefined;
}
const textColor = getTextColor()
const textColor = getTextColor();
// Park the native terminal cursor on the pointer indicator so screen
// readers / magnifiers track the focused item. (0,0) is the top-left of
@@ -163,7 +163,7 @@ export function ListItem({
line: 0,
column: 0,
active: isFocused && !disabled && declareCursor !== false,
})
});
return (
<Box ref={cursorRef} flexDirection="column">
@@ -184,5 +184,5 @@ export function ListItem({
</Box>
)}
</Box>
)
);
}

View File

@@ -1,30 +1,30 @@
import React from 'react'
import { Box, Text } from '../index.js'
import { Spinner } from './Spinner.js'
import React from 'react';
import { Box, Text } from '../index.js';
import { Spinner } from './Spinner.js';
type LoadingStateProps = {
/**
* The loading message to display next to the spinner.
*/
message: string
message: string;
/**
* Display the message in bold.
* @default false
*/
bold?: boolean
bold?: boolean;
/**
* Display the message in dimmed color.
* @default false
*/
dimColor?: boolean
dimColor?: boolean;
/**
* Optional subtitle displayed below the main message.
*/
subtitle?: string
}
subtitle?: string;
};
/**
* A spinner with loading message for async operations.
@@ -62,5 +62,5 @@ export function LoadingState({
</Box>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { useIsInsideModal } from './modalContext.js'
import { Box } from '../index.js'
import type { Theme } from './theme-types.js'
import { Divider } from './Divider.js'
import React from 'react';
import { useIsInsideModal } from './modalContext.js';
import { Box } from '../index.js';
import type { Theme } from './theme-types.js';
import { Divider } from './Divider.js';
type PaneProps = {
children: React.ReactNode
children: React.ReactNode;
/**
* Theme color for the top border line.
*/
color?: keyof Theme
}
color?: keyof Theme;
};
/**
* A pane — a region of the terminal that appears below the REPL prompt,
@@ -44,7 +44,7 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
)
);
}
return (
<Box flexDirection="column" paddingTop={1}>
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
{children}
</Box>
</Box>
)
);
}

View File

@@ -1,48 +1,43 @@
import React from 'react'
import { Text } from '../index.js'
import type { Theme } from './theme-types.js'
import React from 'react';
import { Text } from '../index.js';
import type { Theme } from './theme-types.js';
type Props = {
/**
* How much progress to display, between 0 and 1 inclusive
*/
ratio: number // [0, 1]
ratio: number; // [0, 1]
/**
* How many characters wide to draw the progress bar
*/
width: number // how many characters wide
width: number; // how many characters wide
/**
* Optional color for the filled portion of the bar
*/
fillColor?: keyof Theme
fillColor?: keyof Theme;
/**
* Optional color for the empty portion of the bar
*/
emptyColor?: keyof Theme
}
emptyColor?: keyof Theme;
};
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
export function ProgressBar({
ratio: inputRatio,
width,
fillColor,
emptyColor,
}: Props): React.ReactNode {
const ratio = Math.min(1, Math.max(0, inputRatio))
const whole = Math.floor(ratio * width)
const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)]
export function ProgressBar({ ratio: inputRatio, width, fillColor, emptyColor }: Props): React.ReactNode {
const ratio = Math.min(1, Math.max(0, inputRatio));
const whole = Math.floor(ratio * width);
const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)];
if (whole < width) {
const remainder = ratio * width - whole
const middle = Math.floor(remainder * BLOCKS.length)
segments.push(BLOCKS[middle]!)
const remainder = ratio * width - whole;
const middle = Math.floor(remainder * BLOCKS.length);
segments.push(BLOCKS[middle]!);
const empty = width - whole - 1
const empty = width - whole - 1;
if (empty > 0) {
segments.push(BLOCKS[0]!.repeat(empty))
segments.push(BLOCKS[0]!.repeat(empty));
}
}
@@ -50,5 +45,5 @@ export function ProgressBar({
<Text color={fillColor} backgroundColor={emptyColor}>
{segments.join('')}
</Text>
)
);
}

View File

@@ -1,39 +1,39 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useTerminalViewport } from '../hooks/use-terminal-viewport.js'
import { Box, type DOMElement, measureElement } from '../index.js'
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useTerminalViewport } from '../hooks/use-terminal-viewport.js';
import { Box, type DOMElement, measureElement } from '../index.js';
type Props = {
children: React.ReactNode
lock?: 'always' | 'offscreen'
}
children: React.ReactNode;
lock?: 'always' | 'offscreen';
};
export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode {
const [viewportRef, { isVisible }] = useTerminalViewport()
const { rows } = useTerminalSize()
const innerRef = useRef<DOMElement | null>(null)
const maxHeight = useRef(0)
const [minHeight, setMinHeight] = useState(0)
const [viewportRef, { isVisible }] = useTerminalViewport();
const { rows } = useTerminalSize();
const innerRef = useRef<DOMElement | null>(null);
const maxHeight = useRef(0);
const [minHeight, setMinHeight] = useState(0);
const outerRef = useCallback(
(el: DOMElement | null) => {
viewportRef(el)
viewportRef(el);
},
[viewportRef],
)
);
const engaged = lock === 'always' || !isVisible
const engaged = lock === 'always' || !isVisible;
useLayoutEffect(() => {
if (!innerRef.current) {
return
return;
}
const { height } = measureElement(innerRef.current)
const { height } = measureElement(innerRef.current);
if (height > maxHeight.current) {
maxHeight.current = Math.min(height, rows)
setMinHeight(maxHeight.current)
maxHeight.current = Math.min(height, rows);
setMinHeight(maxHeight.current);
}
})
});
return (
<Box minHeight={engaged ? minHeight : undefined} ref={outerRef}>
@@ -41,5 +41,5 @@ export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode {
{children}
</Box>
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { Box, Text } from '../index.js'
import React from 'react';
import { Box, Text } from '../index.js';
type Props = {
query: string
placeholder?: string
isFocused: boolean
isTerminalFocused: boolean
prefix?: string
width?: number | string
cursorOffset?: number
borderless?: boolean
}
query: string;
placeholder?: string;
isFocused: boolean;
isTerminalFocused: boolean;
prefix?: string;
width?: number | string;
cursorOffset?: number;
borderless?: boolean;
};
export function SearchBox({
query,
@@ -22,7 +22,7 @@ export function SearchBox({
cursorOffset,
borderless = false,
}: Props): React.ReactNode {
const offset = cursorOffset ?? query.length
const offset = cursorOffset ?? query.length;
return (
<Box
@@ -41,12 +41,8 @@ export function SearchBox({
isTerminalFocused ? (
<>
<Text>{query.slice(0, offset)}</Text>
<Text inverse>
{offset < query.length ? query[offset] : ' '}
</Text>
{offset < query.length && (
<Text>{query.slice(offset + 1)}</Text>
)}
<Text inverse>{offset < query.length ? query[offset] : ' '}</Text>
{offset < query.length && <Text>{query.slice(offset + 1)}</Text>}
</>
) : (
<Text>{query}</Text>
@@ -67,5 +63,5 @@ export function SearchBox({
)}
</Text>
</Box>
)
);
}

View File

@@ -1,20 +1,20 @@
import React, { useState, useEffect } from 'react'
import { Text } from '../index.js'
import React, { useState, useEffect } from 'react';
import { Text } from '../index.js';
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/**
* A simple animated spinner for loading states.
*/
export function Spinner(): React.ReactNode {
const [frame, setFrame] = useState(0)
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame(f => (f + 1) % FRAMES.length)
}, 80)
return () => clearInterval(timer)
}, [])
setFrame(f => (f + 1) % FRAMES.length);
}, 80);
return () => clearInterval(timer);
}, []);
return <Text>{FRAMES[frame]}</Text>
return <Text>{FRAMES[frame]}</Text>;
}

View File

@@ -1,8 +1,8 @@
import figures from 'figures'
import React from 'react'
import { Text } from '../index.js'
import figures from 'figures';
import React from 'react';
import { Text } from '../index.js';
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading';
type Props = {
/**
@@ -15,19 +15,19 @@ type Props = {
* - `pending`: Dimmed circle (○)
* - `loading`: Dimmed ellipsis (…)
*/
status: Status
status: Status;
/**
* Include a trailing space after the icon. Useful when followed by text.
* @default false
*/
withSpace?: boolean
}
withSpace?: boolean;
};
const STATUS_CONFIG: Record<
Status,
{
icon: string
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined
icon: string;
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined;
}
> = {
success: { icon: figures.tick, color: 'success' },
@@ -36,7 +36,7 @@ const STATUS_CONFIG: Record<
info: { icon: figures.info, color: 'suggestion' },
pending: { icon: figures.circle, color: undefined },
loading: { icon: '…', color: undefined },
}
};
/**
* Renders a status indicator icon with appropriate color.
@@ -56,16 +56,13 @@ const STATUS_CONFIG: Record<
* Waiting for response
* </Text>
*/
export function StatusIcon({
status,
withSpace = false,
}: Props): React.ReactNode {
const config = STATUS_CONFIG[status]
export function StatusIcon({ status, withSpace = false }: Props): React.ReactNode {
const config = STATUS_CONFIG[status];
return (
<Text color={config.color} dimColor={!config.color}>
{config.icon}
{withSpace && ' '}
</Text>
)
);
}

View File

@@ -1,37 +1,28 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import {
useIsInsideModal,
useModalScrollRef,
} from './modalContext.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import ScrollBox from '../components/ScrollBox.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { stringWidth } from '../core/stringWidth.js'
import { Box, Text } from '../index.js'
import { useKeybindings } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useIsInsideModal, useModalScrollRef } from './modalContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import ScrollBox from '../components/ScrollBox.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { stringWidth } from '../core/stringWidth.js';
import { Box, Text } from '../index.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
type TabsProps = {
children: Array<React.ReactElement<TabProps>>
title?: string
color?: keyof Theme
defaultTab?: string
hidden?: boolean
useFullWidth?: boolean
children: Array<React.ReactElement<TabProps>>;
title?: string;
color?: keyof Theme;
defaultTab?: string;
hidden?: boolean;
useFullWidth?: boolean;
/** Controlled mode: current selected tab id/title */
selectedTab?: string
selectedTab?: string;
/** Controlled mode: callback when tab changes */
onTabChange?: (tabId: string) => void
onTabChange?: (tabId: string) => void;
/** Optional banner to display below tabs header */
banner?: React.ReactNode
banner?: React.ReactNode;
/** Disable keyboard navigation (e.g. when a child component handles arrow keys) */
disableNavigation?: boolean
disableNavigation?: boolean;
/**
* Initial focus state for the tab header row. Defaults to true (header
* focused, nav always works). Keep the default for Select/list content —
@@ -40,29 +31,29 @@ type TabsProps = {
* content actually binds left/right/tab (e.g. enum cycling), and show a
* "↑ tabs" footer hint — without it tabs look broken.
*/
initialHeaderFocused?: boolean
initialHeaderFocused?: boolean;
/**
* Fixed height for the content area. When set, all tabs render within the
* same height (overflow hidden) so switching tabs doesn't cause layout
* shifts. Shorter tabs get whitespace; taller tabs are clipped.
*/
contentHeight?: number
contentHeight?: number;
/**
* Let Tab/←/→ switch tabs from focused content. Opt-in since some
* content uses those keys; pass a reactive boolean to cede them when
* needed. Switching from content focuses the header.
*/
navFromContent?: boolean
}
navFromContent?: boolean;
};
type TabsContextValue = {
selectedTab: string | undefined
width: number | undefined
headerFocused: boolean
focusHeader: () => void
blurHeader: () => void
registerOptIn: () => () => void
}
selectedTab: string | undefined;
width: number | undefined;
headerFocused: boolean;
focusHeader: () => void;
blurHeader: () => void;
registerOptIn: () => () => void;
};
const TabsContext = createContext<TabsContextValue>({
selectedTab: undefined,
@@ -73,7 +64,7 @@ const TabsContext = createContext<TabsContextValue>({
focusHeader: () => {},
blurHeader: () => {},
registerOptIn: () => () => {},
})
});
export function Tabs({
title,
@@ -90,64 +81,51 @@ export function Tabs({
contentHeight,
navFromContent = false,
}: TabsProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const tabs = children.map(child => [
child.props.id ?? child.props.title,
child.props.title,
])
const defaultTabIndex = defaultTab
? tabs.findIndex(tab => defaultTab === tab[0])
: 0
const { columns: terminalWidth } = useTerminalSize();
const tabs = children.map(child => [child.props.id ?? child.props.title, child.props.title]);
const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0;
// Support both controlled and uncontrolled modes
const isControlled = controlledSelectedTab !== undefined
const [internalSelectedTab, setInternalSelectedTab] = useState(
defaultTabIndex !== -1 ? defaultTabIndex : 0,
)
const isControlled = controlledSelectedTab !== undefined;
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0);
// In controlled mode, find the index of the controlled tab
const controlledTabIndex = isControlled
? tabs.findIndex(tab => tab[0] === controlledSelectedTab)
: -1
const selectedTabIndex = isControlled
? controlledTabIndex !== -1
? controlledTabIndex
: 0
: internalSelectedTab
const controlledTabIndex = isControlled ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) : -1;
const selectedTabIndex = isControlled ? (controlledTabIndex !== -1 ? controlledTabIndex : 0) : internalSelectedTab;
const modalScrollRef = useModalScrollRef()
const modalScrollRef = useModalScrollRef();
// Header focus: left/right/tab only switch tabs when the header row is
// focused. Children with interactive content call focusHeader() (via
// useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow
// returns it. Tabs that never call the hook see no behavior change —
// initialHeaderFocused defaults to true so nav always works.
const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused)
const focusHeader = useCallback(() => setHeaderFocused(true), [])
const blurHeader = useCallback(() => setHeaderFocused(false), [])
const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused);
const focusHeader = useCallback(() => setHeaderFocused(true), []);
const blurHeader = useCallback(() => setHeaderFocused(false), []);
// Count of mounted children using useTabHeaderFocus(). Down-arrow blur and
// the ↓ hint only engage when at least one child has opted in — otherwise
// pressing down on a legacy tab would strand the user with nav disabled.
const [optInCount, setOptInCount] = useState(0)
const [optInCount, setOptInCount] = useState(0);
const registerOptIn = useCallback(() => {
setOptInCount(n => n + 1)
return () => setOptInCount(n => n - 1)
}, [])
const optedIn = optInCount > 0
setOptInCount(n => n + 1);
return () => setOptInCount(n => n - 1);
}, []);
const optedIn = optInCount > 0;
const handleTabChange = (offset: number) => {
const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length
const newTabId = tabs[newIndex]?.[0]
const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length;
const newTabId = tabs[newIndex]?.[0];
if (isControlled && onTabChange && newTabId) {
onTabChange(newTabId)
onTabChange(newTabId);
} else {
setInternalSelectedTab(newIndex)
setInternalSelectedTab(newIndex);
}
// Tab switching is a header action — stay focused so the user can keep
// cycling. The newly mounted tab can blur via its own interaction.
setHeaderFocused(true)
}
setHeaderFocused(true);
};
useKeybindings(
{
@@ -158,54 +136,49 @@ export function Tabs({
context: 'Tabs',
isActive: !hidden && !disableNavigation && headerFocused,
},
)
);
// When the header is focused, down-arrow returns focus to content. Only
// active when the selected tab has opted in via useTabHeaderFocus() —
// legacy tabs have nowhere to return focus to.
const handleKeyDown = (e: KeyboardEvent) => {
if (!headerFocused || !optedIn || hidden) return
if (!headerFocused || !optedIn || hidden) return;
if (e.key === 'down') {
e.preventDefault()
setHeaderFocused(false)
e.preventDefault();
setHeaderFocused(false);
}
}
};
// Opt-in: same tabs:next/previous actions, active from content. Focuses
// the header so subsequent presses cycle via the handler above.
useKeybindings(
{
'tabs:next': () => {
handleTabChange(1)
setHeaderFocused(true)
handleTabChange(1);
setHeaderFocused(true);
},
'tabs:previous': () => {
handleTabChange(-1)
setHeaderFocused(true)
handleTabChange(-1);
setHeaderFocused(true);
},
},
{
context: 'Tabs',
isActive:
navFromContent &&
!headerFocused &&
optedIn &&
!hidden &&
!disableNavigation,
isActive: navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation,
},
)
);
// Calculate spacing to fill the available width. No keyboard hint in the
// header row — content footers own hints (see useTabHeaderFocus docs).
const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap
const titleWidth = title ? stringWidth(title) + 1 : 0; // +1 for gap
const tabsWidth = tabs.reduce(
(sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap
0,
)
const usedWidth = titleWidth + tabsWidth
const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0
);
const usedWidth = titleWidth + tabsWidth;
const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0;
const contentWidth = useFullWidth ? terminalWidth : undefined
const contentWidth = useFullWidth ? terminalWidth : undefined;
return (
<TabsContext.Provider
@@ -230,19 +203,15 @@ export function Tabs({
flexShrink={modalScrollRef ? 0 : undefined}
>
{!hidden && (
<Box
flexDirection="row"
gap={1}
flexShrink={modalScrollRef ? 0 : undefined}
>
<Box flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>
{title !== undefined && (
<Text bold color={color}>
{title}
</Text>
)}
{tabs.map(([id, title], i) => {
const isCurrent = selectedTabIndex === i
const hasColorCursor = color && isCurrent && headerFocused
const isCurrent = selectedTabIndex === i;
const hasColorCursor = color && isCurrent && headerFocused;
return (
<Text
key={id}
@@ -254,7 +223,7 @@ export function Tabs({
{' '}
{title}{' '}
</Text>
)
);
})}
{spacerWidth > 0 && <Text>{' '.repeat(spacerWidth)}</Text>}
</Box>
@@ -267,12 +236,7 @@ export function Tabs({
// ModalContext. Keyed by selectedTabIndex → remounts on tab
// switch, resetting scrollTop to 0 without scrollTo() timing games.
<Box width={contentWidth} marginTop={hidden ? 0 : 1} flexShrink={0}>
<ScrollBox
key={selectedTabIndex}
ref={modalScrollRef}
flexDirection="column"
flexShrink={0}
>
<ScrollBox key={selectedTabIndex} ref={modalScrollRef} flexDirection="column" flexShrink={0}>
{children}
</ScrollBox>
</Box>
@@ -288,32 +252,32 @@ export function Tabs({
)}
</Box>
</TabsContext.Provider>
)
);
}
type TabProps = {
title: string
id?: string
children: React.ReactNode
}
title: string;
id?: string;
children: React.ReactNode;
};
export function Tab({ title, id, children }: TabProps): React.ReactNode {
const { selectedTab, width } = useContext(TabsContext)
const insideModal = useIsInsideModal()
const { selectedTab, width } = useContext(TabsContext);
const insideModal = useIsInsideModal();
if (selectedTab !== (id ?? title)) {
return null
return null;
}
return (
<Box width={width} flexShrink={insideModal ? 0 : undefined}>
{children}
</Box>
)
);
}
export function useTabsWidth(): number | undefined {
const { width } = useContext(TabsContext)
return width
const { width } = useContext(TabsContext);
return width;
}
/**
@@ -328,12 +292,11 @@ export function useTabsWidth(): number | undefined {
* when the Select renders.
*/
export function useTabHeaderFocus(): {
headerFocused: boolean
focusHeader: () => void
blurHeader: () => void
headerFocused: boolean;
focusHeader: () => void;
blurHeader: () => void;
} {
const { headerFocused, focusHeader, blurHeader, registerOptIn } =
useContext(TabsContext)
useEffect(registerOptIn, [registerOptIn])
return { headerFocused, focusHeader, blurHeader }
const { headerFocused, focusHeader, blurHeader, registerOptIn } = useContext(TabsContext);
useEffect(registerOptIn, [registerOptIn]);
return { headerFocused, focusHeader, blurHeader };
}

View File

@@ -1,44 +1,38 @@
import { feature } from 'bun:bundle'
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useStdin from '../hooks/use-stdin.js'
import { getSystemThemeName, type SystemTheme } from './systemTheme.js'
import type { ThemeName, ThemeSetting } from './theme-types.js'
import { feature } from 'bun:bundle';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import useStdin from '../hooks/use-stdin.js';
import { getSystemThemeName, type SystemTheme } from './systemTheme.js';
import type { ThemeName, ThemeSetting } from './theme-types.js';
// -- Config persistence injection --
// Business layer provides these via setThemeConfigCallbacks().
// Defaults read/write from a simple module-level store.
let _loadTheme: () => ThemeSetting = () => 'dark'
let _saveTheme: (setting: ThemeSetting) => void = () => {}
let _loadTheme: () => ThemeSetting = () => 'dark';
let _saveTheme: (setting: ThemeSetting) => void = () => {};
/** Inject config persistence from the business layer. Call once at startup. */
export function setThemeConfigCallbacks(opts: {
loadTheme: () => ThemeSetting
saveTheme: (setting: ThemeSetting) => void
loadTheme: () => ThemeSetting;
saveTheme: (setting: ThemeSetting) => void;
}): void {
_loadTheme = opts.loadTheme
_saveTheme = opts.saveTheme
_loadTheme = opts.loadTheme;
_saveTheme = opts.saveTheme;
}
type ThemeContextValue = {
/** The saved user preference. May be 'auto'. */
themeSetting: ThemeSetting
setThemeSetting: (setting: ThemeSetting) => void
setPreviewTheme: (setting: ThemeSetting) => void
savePreview: () => void
cancelPreview: () => void
themeSetting: ThemeSetting;
setThemeSetting: (setting: ThemeSetting) => void;
setPreviewTheme: (setting: ThemeSetting) => void;
savePreview: () => void;
cancelPreview: () => void;
/** The resolved theme to render with. Never 'auto'. */
currentTheme: ThemeName
}
currentTheme: ThemeName;
};
// Non-'auto' default so useTheme() works without a provider (tests, tooling).
const DEFAULT_THEME: ThemeName = 'dark'
const DEFAULT_THEME: ThemeName = 'dark';
const ThemeContext = createContext<ThemeContextValue>({
themeSetting: DEFAULT_THEME,
@@ -47,105 +41,96 @@ const ThemeContext = createContext<ThemeContextValue>({
savePreview: () => {},
cancelPreview: () => {},
currentTheme: DEFAULT_THEME,
})
});
type Props = {
children: React.ReactNode
initialState?: ThemeSetting
onThemeSave?: (setting: ThemeSetting) => void
}
children: React.ReactNode;
initialState?: ThemeSetting;
onThemeSave?: (setting: ThemeSetting) => void;
};
function defaultInitialTheme(): ThemeSetting {
return _loadTheme()
return _loadTheme();
}
function defaultSaveTheme(setting: ThemeSetting): void {
_saveTheme(setting)
_saveTheme(setting);
}
export function ThemeProvider({
children,
initialState,
onThemeSave = defaultSaveTheme,
}: Props) {
const [themeSetting, setThemeSetting] = useState(
initialState ?? defaultInitialTheme,
)
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null)
export function ThemeProvider({ children, initialState, onThemeSave = defaultSaveTheme }: Props) {
const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme);
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null);
// Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or
// 'dark' if unset); the OSC 11 watcher corrects it on first poll.
const [systemTheme, setSystemTheme] = useState<SystemTheme>(() =>
(initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark',
)
);
// The setting currently in effect (preview wins while picker is open)
const activeSetting = previewTheme ?? themeSetting
const activeSetting = previewTheme ?? themeSetting;
const { internal_querier } = useStdin()
const { internal_querier } = useStdin();
// Watch for live terminal theme changes while 'auto' is active.
// Positive feature() pattern so the watcher import is dead-code-eliminated
// in external builds.
useEffect(() => {
if (feature('AUTO_THEME')) {
if (activeSetting !== 'auto' || !internal_querier) return
let cleanup: (() => void) | undefined
let cancelled = false
void import('../../utils/systemThemeWatcher.js').then(
({ watchSystemTheme }) => {
if (cancelled) return
cleanup = watchSystemTheme(internal_querier, setSystemTheme)
},
)
if (activeSetting !== 'auto' || !internal_querier) return;
let cleanup: (() => void) | undefined;
let cancelled = false;
void import('../../utils/systemThemeWatcher.js').then(({ watchSystemTheme }) => {
if (cancelled) return;
cleanup = watchSystemTheme(internal_querier, setSystemTheme);
});
return () => {
cancelled = true
cleanup?.()
}
cancelled = true;
cleanup?.();
};
}
}, [activeSetting, internal_querier])
}, [activeSetting, internal_querier]);
const currentTheme: ThemeName =
activeSetting === 'auto' ? systemTheme : activeSetting
const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting;
const value = useMemo<ThemeContextValue>(
() => ({
themeSetting,
setThemeSetting: (newSetting: ThemeSetting) => {
setThemeSetting(newSetting)
setPreviewTheme(null)
setThemeSetting(newSetting);
setPreviewTheme(null);
// Switching to 'auto' restarts the watcher (activeSetting dep), whose
// first poll fires immediately. Seed from the cache so the OSC
// round-trip doesn't flash the wrong palette.
if (newSetting === 'auto') {
setSystemTheme(getSystemThemeName())
setSystemTheme(getSystemThemeName());
}
onThemeSave?.(newSetting)
onThemeSave?.(newSetting);
},
setPreviewTheme: (newSetting: ThemeSetting) => {
setPreviewTheme(newSetting)
setPreviewTheme(newSetting);
if (newSetting === 'auto') {
setSystemTheme(getSystemThemeName())
setSystemTheme(getSystemThemeName());
}
},
savePreview: () => {
if (previewTheme !== null) {
setThemeSetting(previewTheme)
setPreviewTheme(null)
onThemeSave?.(previewTheme)
setThemeSetting(previewTheme);
setPreviewTheme(null);
onThemeSave?.(previewTheme);
}
},
cancelPreview: () => {
if (previewTheme !== null) {
setPreviewTheme(null)
setPreviewTheme(null);
}
},
currentTheme,
}),
[themeSetting, previewTheme, currentTheme, onThemeSave],
)
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
@@ -153,8 +138,8 @@ export function ThemeProvider({
* accepts any ThemeSetting (including 'auto').
*/
export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] {
const { currentTheme, setThemeSetting } = useContext(ThemeContext)
return [currentTheme, setThemeSetting]
const { currentTheme, setThemeSetting } = useContext(ThemeContext);
return [currentTheme, setThemeSetting];
}
/**
@@ -162,11 +147,10 @@ export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] {
* needs to show 'auto' as a distinct choice (e.g., ThemePicker).
*/
export function useThemeSetting(): ThemeSetting {
return useContext(ThemeContext).themeSetting
return useContext(ThemeContext).themeSetting;
}
export function usePreviewTheme() {
const { setPreviewTheme, savePreview, cancelPreview } =
useContext(ThemeContext)
return { setPreviewTheme, savePreview, cancelPreview }
const { setPreviewTheme, savePreview, cancelPreview } = useContext(ThemeContext);
return { setPreviewTheme, savePreview, cancelPreview };
}

View File

@@ -1,22 +1,22 @@
import React, { type PropsWithChildren, type Ref } from 'react'
import Box from '../components/Box.js'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Color, Styles } from '../core/styles.js'
import { getTheme, type Theme } from './theme-types.js'
import { useTheme } from './ThemeProvider.js'
import React, { type PropsWithChildren, type Ref } from 'react';
import Box from '../components/Box.js';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Color, Styles } from '../core/styles.js';
import { getTheme, type Theme } from './theme-types.js';
import { useTheme } from './ThemeProvider.js';
// Color props that accept theme keys
type ThemedColorProps = {
readonly borderColor?: keyof Theme | Color
readonly borderTopColor?: keyof Theme | Color
readonly borderBottomColor?: keyof Theme | Color
readonly borderLeftColor?: keyof Theme | Color
readonly borderRightColor?: keyof Theme | Color
readonly backgroundColor?: keyof Theme | Color
}
readonly borderColor?: keyof Theme | Color;
readonly borderTopColor?: keyof Theme | Color;
readonly borderBottomColor?: keyof Theme | Color;
readonly borderLeftColor?: keyof Theme | Color;
readonly borderRightColor?: keyof Theme | Color;
readonly backgroundColor?: keyof Theme | Color;
};
// Base Styles without color props (they'll be overridden)
type BaseStylesWithoutColors = Omit<
@@ -28,43 +28,35 @@ type BaseStylesWithoutColors = Omit<
| 'borderLeftColor'
| 'borderRightColor'
| 'backgroundColor'
>
>;
export type Props = BaseStylesWithoutColors &
ThemedColorProps & {
ref?: Ref<DOMElement>
tabIndex?: number
autoFocus?: boolean
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
}
ref?: Ref<DOMElement>;
tabIndex?: number;
autoFocus?: boolean;
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
/**
* Resolves a color value that may be a theme key to a raw Color.
*/
function resolveColor(
color: keyof Theme | Color | undefined,
theme: Theme,
): Color | undefined {
if (!color) return undefined
function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined {
if (!color) return undefined;
// Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:)
if (
color.startsWith('rgb(') ||
color.startsWith('#') ||
color.startsWith('ansi256(') ||
color.startsWith('ansi:')
) {
return color as Color
if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) {
return color as Color;
}
// It's a theme key - resolve it
return theme[color as keyof Theme] as Color
return theme[color as keyof Theme] as Color;
}
/**
@@ -82,16 +74,16 @@ function ThemedBox({
ref,
...rest
}: PropsWithChildren<Props>): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const [themeName] = useTheme();
const theme = getTheme(themeName);
// Resolve theme keys to raw colors
const resolvedBorderColor = resolveColor(borderColor, theme)
const resolvedBorderTopColor = resolveColor(borderTopColor, theme)
const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme)
const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme)
const resolvedBorderRightColor = resolveColor(borderRightColor, theme)
const resolvedBackgroundColor = resolveColor(backgroundColor, theme)
const resolvedBorderColor = resolveColor(borderColor, theme);
const resolvedBorderTopColor = resolveColor(borderTopColor, theme);
const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme);
const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme);
const resolvedBorderRightColor = resolveColor(borderRightColor, theme);
const resolvedBackgroundColor = resolveColor(backgroundColor, theme);
return (
<Box
@@ -106,7 +98,7 @@ function ThemedBox({
>
{children}
</Box>
)
);
}
export default ThemedBox
export default ThemedBox;

View File

@@ -1,87 +1,77 @@
import type { ReactNode } from 'react'
import React, { useContext } from 'react'
import Text from '../components/Text.js'
import type { Color, Styles } from '../core/styles.js'
import { getTheme, type Theme } from './theme-types.js'
import { useTheme } from './ThemeProvider.js'
import type { ReactNode } from 'react';
import React, { useContext } from 'react';
import Text from '../components/Text.js';
import type { Color, Styles } from '../core/styles.js';
import { getTheme, type Theme } from './theme-types.js';
import { useTheme } from './ThemeProvider.js';
/** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` >
* this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */
export const TextHoverColorContext = React.createContext<
keyof Theme | undefined
>(undefined)
export const TextHoverColorContext = React.createContext<keyof Theme | undefined>(undefined);
export type Props = {
/**
* Change text color. Accepts a theme key or raw color value.
*/
readonly color?: keyof Theme | Color
readonly color?: keyof Theme | Color;
/**
* Same as `color`, but for background. Must be a theme key.
*/
readonly backgroundColor?: keyof Theme
readonly backgroundColor?: keyof Theme;
/**
* Dim the color using the theme's inactive color.
* This is compatible with bold (unlike ANSI dim).
*/
readonly dimColor?: boolean
readonly dimColor?: boolean;
/**
* Make the text bold.
*/
readonly bold?: boolean
readonly bold?: boolean;
/**
* Make the text italic.
*/
readonly italic?: boolean
readonly italic?: boolean;
/**
* Make the text underlined.
*/
readonly underline?: boolean
readonly underline?: boolean;
/**
* Make the text crossed with a line.
*/
readonly strikethrough?: boolean
readonly strikethrough?: boolean;
/**
* Inverse background and foreground colors.
*/
readonly inverse?: boolean
readonly inverse?: boolean;
/**
* This property tells Ink to wrap or truncate text if its width is larger than container.
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap']
readonly wrap?: Styles['textWrap'];
readonly children?: ReactNode
}
readonly children?: ReactNode;
};
/**
* Resolves a color value that may be a theme key to a raw Color.
*/
function resolveColor(
color: keyof Theme | Color | undefined,
theme: Theme,
): Color | undefined {
if (!color) return undefined
function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined {
if (!color) return undefined;
// Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:)
if (
color.startsWith('rgb(') ||
color.startsWith('#') ||
color.startsWith('ansi256(') ||
color.startsWith('ansi:')
) {
return color as Color
if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) {
return color as Color;
}
// It's a theme key - resolve it
return theme[color as keyof Theme] as Color
return theme[color as keyof Theme] as Color;
}
/**
@@ -100,9 +90,9 @@ export default function ThemedText({
wrap = 'wrap',
children,
}: Props): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const hoverColor = useContext(TextHoverColorContext)
const [themeName] = useTheme();
const theme = getTheme(themeName);
const hoverColor = useContext(TextHoverColorContext);
// Resolve theme keys to raw colors
const resolvedColor =
@@ -110,10 +100,8 @@ export default function ThemedText({
? resolveColor(hoverColor, theme)
: dimColor
? (theme.inactive as Color)
: resolveColor(color, theme)
const resolvedBackgroundColor = backgroundColor
? (theme[backgroundColor] as Color)
: undefined
: resolveColor(color, theme);
const resolvedBackgroundColor = backgroundColor ? (theme[backgroundColor] as Color) : undefined;
return (
<Text
@@ -128,5 +116,5 @@ export default function ThemedText({
>
{children}
</Text>
)
);
}