mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-2): 格式化 commands (79 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-5): 格式化 components其余 + hooks + tools (232 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files) 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md - README.md: 大幅重写,更详细版本历史和配置示例 - Run.ps1: 新增 Windows 启动脚本 - TODO.md: 新增包完成清单 - V6.md: 删除(架构重构规划已不适用) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复以前的问题 * fix: 修复 login 面板的问题 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { Children, isValidElement } from 'react';
|
||||
import { Text } from '../../ink.js';
|
||||
import React, { Children, isValidElement } from 'react'
|
||||
import { Text } from '../../ink.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.
|
||||
@@ -34,43 +34,24 @@ type Props = {
|
||||
* </Text>
|
||||
*
|
||||
*/
|
||||
export function Byline(t0) {
|
||||
const $ = _c(5);
|
||||
const {
|
||||
children
|
||||
} = t0;
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[0] !== children) {
|
||||
t2 = Symbol.for("react.early_return_sentinel");
|
||||
bb0: {
|
||||
const validChildren = Children.toArray(children);
|
||||
if (validChildren.length === 0) {
|
||||
t2 = null;
|
||||
break bb0;
|
||||
}
|
||||
t1 = validChildren.map(_temp);
|
||||
}
|
||||
$[0] = children;
|
||||
$[1] = t1;
|
||||
$[2] = t2;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
t2 = $[2];
|
||||
export function Byline({ children }: Props): React.ReactNode {
|
||||
// Children.toArray already filters out null, undefined, and booleans
|
||||
const validChildren = Children.toArray(children)
|
||||
|
||||
if (validChildren.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (t2 !== Symbol.for("react.early_return_sentinel")) {
|
||||
return t2;
|
||||
}
|
||||
let t3;
|
||||
if ($[3] !== t1) {
|
||||
t3 = <>{t1}</>;
|
||||
$[3] = t1;
|
||||
$[4] = t3;
|
||||
} else {
|
||||
t3 = $[4];
|
||||
}
|
||||
return t3;
|
||||
}
|
||||
function _temp(child, index) {
|
||||
return <React.Fragment key={isValidElement(child) ? child.key ?? index : index}>{index > 0 && <Text dimColor={true}> · </Text>}{child}</React.Fragment>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{validChildren.map((child, index) => (
|
||||
<React.Fragment
|
||||
key={isValidElement(child) ? (child.key ?? index) : index}
|
||||
>
|
||||
{index > 0 && <Text dimColor> · </Text>}
|
||||
{child}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { Theme } from '../../utils/theme.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/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { Box, Text } from '../../ink.js'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { Theme } from '../../utils/theme.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
|
||||
@@ -25,113 +28,73 @@ 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;
|
||||
};
|
||||
export function Dialog(t0) {
|
||||
const $ = _c(27);
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
onCancel,
|
||||
color: t1,
|
||||
hideInputGuide,
|
||||
hideBorder,
|
||||
inputGuide,
|
||||
isCancelActive: t2
|
||||
} = t0;
|
||||
const color = t1 === undefined ? "permission" : t1;
|
||||
const isCancelActive = t2 === undefined ? true : t2;
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
|
||||
let t3;
|
||||
if ($[0] !== isCancelActive) {
|
||||
t3 = {
|
||||
context: "Confirmation",
|
||||
isActive: isCancelActive
|
||||
};
|
||||
$[0] = isCancelActive;
|
||||
$[1] = t3;
|
||||
} else {
|
||||
t3 = $[1];
|
||||
}
|
||||
useKeybinding("confirm:no", onCancel, t3);
|
||||
let t4;
|
||||
if ($[2] !== exitState.keyName || $[3] !== exitState.pending) {
|
||||
t4 = 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" /></Byline>;
|
||||
$[2] = exitState.keyName;
|
||||
$[3] = exitState.pending;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
}
|
||||
const defaultInputGuide = t4;
|
||||
let t5;
|
||||
if ($[5] !== color || $[6] !== title) {
|
||||
t5 = <Text bold={true} color={color}>{title}</Text>;
|
||||
$[5] = color;
|
||||
$[6] = title;
|
||||
$[7] = t5;
|
||||
} else {
|
||||
t5 = $[7];
|
||||
}
|
||||
let t6;
|
||||
if ($[8] !== subtitle) {
|
||||
t6 = subtitle && <Text dimColor={true}>{subtitle}</Text>;
|
||||
$[8] = subtitle;
|
||||
$[9] = t6;
|
||||
} else {
|
||||
t6 = $[9];
|
||||
}
|
||||
let t7;
|
||||
if ($[10] !== t5 || $[11] !== t6) {
|
||||
t7 = <Box flexDirection="column">{t5}{t6}</Box>;
|
||||
$[10] = t5;
|
||||
$[11] = t6;
|
||||
$[12] = t7;
|
||||
} else {
|
||||
t7 = $[12];
|
||||
}
|
||||
let t8;
|
||||
if ($[13] !== children || $[14] !== t7) {
|
||||
t8 = <Box flexDirection="column" gap={1}>{t7}{children}</Box>;
|
||||
$[13] = children;
|
||||
$[14] = t7;
|
||||
$[15] = t8;
|
||||
} else {
|
||||
t8 = $[15];
|
||||
}
|
||||
let t9;
|
||||
if ($[16] !== defaultInputGuide || $[17] !== exitState || $[18] !== hideInputGuide || $[19] !== inputGuide) {
|
||||
t9 = !hideInputGuide && <Box marginTop={1}><Text dimColor={true} italic={true}>{inputGuide ? inputGuide(exitState) : defaultInputGuide}</Text></Box>;
|
||||
$[16] = defaultInputGuide;
|
||||
$[17] = exitState;
|
||||
$[18] = hideInputGuide;
|
||||
$[19] = inputGuide;
|
||||
$[20] = t9;
|
||||
} else {
|
||||
t9 = $[20];
|
||||
}
|
||||
let t10;
|
||||
if ($[21] !== t8 || $[22] !== t9) {
|
||||
t10 = <>{t8}{t9}</>;
|
||||
$[21] = t8;
|
||||
$[22] = t9;
|
||||
$[23] = t10;
|
||||
} else {
|
||||
t10 = $[23];
|
||||
}
|
||||
const content = t10;
|
||||
if (hideBorder) {
|
||||
return content;
|
||||
}
|
||||
let t11;
|
||||
if ($[24] !== color || $[25] !== content) {
|
||||
t11 = <Pane color={color}>{content}</Pane>;
|
||||
$[24] = color;
|
||||
$[25] = content;
|
||||
$[26] = t11;
|
||||
} else {
|
||||
t11 = $[26];
|
||||
}
|
||||
return t11;
|
||||
isCancelActive?: boolean
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
onCancel,
|
||||
color = 'permission',
|
||||
hideInputGuide,
|
||||
hideBorder,
|
||||
inputGuide,
|
||||
isCancelActive = true,
|
||||
}: DialogProps): React.ReactNode {
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(
|
||||
undefined,
|
||||
undefined,
|
||||
isCancelActive,
|
||||
)
|
||||
|
||||
// Use configurable keybinding for ESC to cancel.
|
||||
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
|
||||
// an embedded TextInput is focused, so that keys like 'n' reach the field
|
||||
// instead of being consumed here.
|
||||
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"
|
||||
/>
|
||||
</Byline>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={color}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && <Text dimColor>{subtitle}</Text>}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
{!hideInputGuide && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
{inputGuide ? inputGuide(exitState) : defaultInputGuide}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (hideBorder) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <Pane color={color}>{content}</Pane>
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { stringWidth } from '../../ink/stringWidth.js';
|
||||
import { Ansi, Text } from '../../ink.js';
|
||||
import type { Theme } from '../../utils/theme.js';
|
||||
import React from 'react'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { stringWidth } from '../../ink/stringWidth.js'
|
||||
import { Ansi, Text } from '../../ink.js'
|
||||
import type { Theme } from '../../utils/theme.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,86 +63,35 @@ type DividerProps = {
|
||||
* // With centered title
|
||||
* <Divider title="3 new messages" />
|
||||
*/
|
||||
export function Divider(t0) {
|
||||
const $ = _c(21);
|
||||
const {
|
||||
width,
|
||||
color,
|
||||
char: t1,
|
||||
padding: t2,
|
||||
title
|
||||
} = t0;
|
||||
const char = t1 === undefined ? "\u2500" : t1;
|
||||
const padding = t2 === undefined ? 0 : t2;
|
||||
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;
|
||||
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
|
||||
const leftWidth = Math.floor(sideWidth / 2);
|
||||
const rightWidth = sideWidth - leftWidth;
|
||||
const t3 = !color;
|
||||
let t4;
|
||||
if ($[0] !== char || $[1] !== leftWidth) {
|
||||
t4 = char.repeat(leftWidth);
|
||||
$[0] = char;
|
||||
$[1] = leftWidth;
|
||||
$[2] = t4;
|
||||
} else {
|
||||
t4 = $[2];
|
||||
}
|
||||
let t5;
|
||||
if ($[3] !== title) {
|
||||
t5 = <Text dimColor={true}><Ansi>{title}</Ansi></Text>;
|
||||
$[3] = title;
|
||||
$[4] = t5;
|
||||
} else {
|
||||
t5 = $[4];
|
||||
}
|
||||
let t6;
|
||||
if ($[5] !== char || $[6] !== rightWidth) {
|
||||
t6 = char.repeat(rightWidth);
|
||||
$[5] = char;
|
||||
$[6] = rightWidth;
|
||||
$[7] = t6;
|
||||
} else {
|
||||
t6 = $[7];
|
||||
}
|
||||
let t7;
|
||||
if ($[8] !== color || $[9] !== t3 || $[10] !== t4 || $[11] !== t5 || $[12] !== t6) {
|
||||
t7 = <Text color={color} dimColor={t3}>{t4}{" "}{t5}{" "}{t6}</Text>;
|
||||
$[8] = color;
|
||||
$[9] = t3;
|
||||
$[10] = t4;
|
||||
$[11] = t5;
|
||||
$[12] = t6;
|
||||
$[13] = t7;
|
||||
} else {
|
||||
t7 = $[13];
|
||||
}
|
||||
return t7;
|
||||
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)}{' '}
|
||||
<Text dimColor>
|
||||
<Ansi>{title}</Ansi>
|
||||
</Text>{' '}
|
||||
{char.repeat(rightWidth)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
const t3 = !color;
|
||||
let t4;
|
||||
if ($[14] !== char || $[15] !== effectiveWidth) {
|
||||
t4 = char.repeat(effectiveWidth);
|
||||
$[14] = char;
|
||||
$[15] = effectiveWidth;
|
||||
$[16] = t4;
|
||||
} else {
|
||||
t4 = $[16];
|
||||
}
|
||||
let t5;
|
||||
if ($[17] !== color || $[18] !== t3 || $[19] !== t4) {
|
||||
t5 = <Text color={color} dimColor={t3}>{t4}</Text>;
|
||||
$[17] = color;
|
||||
$[18] = t3;
|
||||
$[19] = t4;
|
||||
$[20] = t5;
|
||||
} else {
|
||||
t5 = $[20];
|
||||
}
|
||||
return t5;
|
||||
|
||||
return (
|
||||
<Text color={color} dimColor={!color}>
|
||||
{char.repeat(effectiveWidth)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,73 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
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 '../../ink/events/keyboard-event.js';
|
||||
import { clamp } from '../../ink/layout/geometry.js';
|
||||
import { Box, Text, useTerminalFocus } from '../../ink.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 '../../ink/events/keyboard-event.js'
|
||||
import { clamp } from '../../ink/layout/geometry.js'
|
||||
import { Box, Text, useTerminalFocus } from '../../ink.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;
|
||||
};
|
||||
const DEFAULT_VISIBLE = 8;
|
||||
matchLabel?: string
|
||||
selectAction?: string
|
||||
extraHints?: React.ReactNode
|
||||
}
|
||||
|
||||
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,
|
||||
placeholder = 'Type to search…',
|
||||
@@ -85,117 +88,168 @@ export function FuzzyPicker<T>({
|
||||
emptyMessage = 'No results',
|
||||
matchLabel,
|
||||
selectAction = 'select',
|
||||
extraHints
|
||||
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
|
||||
// still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so
|
||||
// a held backspace doesn't eject the user from the dialog.
|
||||
const {
|
||||
query,
|
||||
cursorOffset
|
||||
} = useSearchInput({
|
||||
const { query, cursorOffset } = useSearchInput({
|
||||
isActive: true,
|
||||
onExit: () => {},
|
||||
onCancel,
|
||||
initialQuery,
|
||||
backspaceExitsOnEmpty: false
|
||||
});
|
||||
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;
|
||||
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
||||
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;
|
||||
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
||||
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]);
|
||||
const focused = items[focusedIndex];
|
||||
setFocusedIndex(i => clamp(i, 0, items.length - 1))
|
||||
}, [items.length])
|
||||
|
||||
const focused = items[focusedIndex]
|
||||
useEffect(() => {
|
||||
onFocus?.(focused);
|
||||
onFocus?.(focused)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focused]);
|
||||
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 searchBox = <SearchBox query={query} cursorOffset={cursorOffset} placeholder={placeholder} isFocused isTerminalFocused={isTerminalFocused} />;
|
||||
const listBlock = <List visible={visible} windowStart={windowStart} visibleCount={visibleCount} total={items.length} focusedIndex={focusedIndex} direction={direction} getKey={getKey} renderItem={renderItem} emptyText={emptyText} />;
|
||||
const preview = renderPreview && focused ? <Box flexDirection="column" flexGrow={1}>
|
||||
}, [focused])
|
||||
|
||||
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 searchBox = (
|
||||
<SearchBox
|
||||
query={query}
|
||||
cursorOffset={cursorOffset}
|
||||
placeholder={placeholder}
|
||||
isFocused
|
||||
isTerminalFocused={isTerminalFocused}
|
||||
/>
|
||||
)
|
||||
|
||||
const listBlock = (
|
||||
<List
|
||||
visible={visible}
|
||||
windowStart={windowStart}
|
||||
visibleCount={visibleCount}
|
||||
total={items.length}
|
||||
focusedIndex={focusedIndex}
|
||||
direction={direction}
|
||||
getKey={getKey}
|
||||
renderItem={renderItem}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
)
|
||||
|
||||
const preview =
|
||||
renderPreview && focused ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{renderPreview(focused)}
|
||||
</Box> : null;
|
||||
</Box>
|
||||
) : 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)}>
|
||||
const listGroup =
|
||||
renderPreview && previewPosition === 'right' ? (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
height={visibleCount + (matchLabel ? 1 : 0)}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{listBlock}
|
||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||
</Box>
|
||||
{preview ?? <Box flexGrow={1} />}
|
||||
</Box> :
|
||||
// Box (not fragment) so the outer gap={1} doesn't insert a blank line
|
||||
// between list/matchLabel/preview — that read as extra space above the
|
||||
// prompt in direction='up'.
|
||||
<Box flexDirection="column">
|
||||
</Box>
|
||||
) : (
|
||||
// Box (not fragment) so the outer gap={1} doesn't insert a blank line
|
||||
// between list/matchLabel/preview — that read as extra space above the
|
||||
// prompt in direction='up'.
|
||||
<Box flexDirection="column">
|
||||
{listBlock}
|
||||
{matchLabel && <Text dimColor>{matchLabel}</Text>}
|
||||
{preview}
|
||||
</Box>;
|
||||
const inputAbove = direction !== 'up';
|
||||
return <Pane color="permission">
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const inputAbove = direction !== 'up'
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Text bold color="permission">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -204,108 +258,93 @@ 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>;
|
||||
</Pane>
|
||||
)
|
||||
}
|
||||
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
|
||||
visible: readonly T[];
|
||||
windowStart: number;
|
||||
total: number;
|
||||
focusedIndex: number;
|
||||
emptyText: string;
|
||||
};
|
||||
function List(t0) {
|
||||
const $ = _c(27);
|
||||
const {
|
||||
visible,
|
||||
windowStart,
|
||||
visibleCount,
|
||||
total,
|
||||
focusedIndex,
|
||||
direction,
|
||||
getKey,
|
||||
renderItem,
|
||||
emptyText
|
||||
} = t0;
|
||||
|
||||
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,
|
||||
windowStart,
|
||||
visibleCount,
|
||||
total,
|
||||
focusedIndex,
|
||||
direction,
|
||||
getKey,
|
||||
renderItem,
|
||||
emptyText,
|
||||
}: ListProps<T>): React.ReactNode {
|
||||
if (visible.length === 0) {
|
||||
let t1;
|
||||
if ($[0] !== emptyText) {
|
||||
t1 = <Text dimColor={true}>{emptyText}</Text>;
|
||||
$[0] = emptyText;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
let t2;
|
||||
if ($[2] !== t1 || $[3] !== visibleCount) {
|
||||
t2 = <Box height={visibleCount} flexShrink={0}>{t1}</Box>;
|
||||
$[2] = t1;
|
||||
$[3] = visibleCount;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
return t2;
|
||||
return (
|
||||
<Box height={visibleCount} flexShrink={0}>
|
||||
<Text dimColor>{emptyText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
let t1;
|
||||
if ($[5] !== direction || $[6] !== focusedIndex || $[7] !== getKey || $[8] !== renderItem || $[9] !== total || $[10] !== visible || $[11] !== visibleCount || $[12] !== windowStart) {
|
||||
let t2;
|
||||
if ($[14] !== direction || $[15] !== focusedIndex || $[16] !== getKey || $[17] !== renderItem || $[18] !== total || $[19] !== visible.length || $[20] !== visibleCount || $[21] !== windowStart) {
|
||||
t2 = (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;
|
||||
return <ListItem key={getKey(item)} isFocused={isFocused} showScrollUp={direction === "up" ? atHighEdge : atLowEdge} showScrollDown={direction === "up" ? atLowEdge : atHighEdge} styled={false}>{renderItem(item, isFocused)}</ListItem>;
|
||||
};
|
||||
$[14] = direction;
|
||||
$[15] = focusedIndex;
|
||||
$[16] = getKey;
|
||||
$[17] = renderItem;
|
||||
$[18] = total;
|
||||
$[19] = visible.length;
|
||||
$[20] = visibleCount;
|
||||
$[21] = windowStart;
|
||||
$[22] = t2;
|
||||
} else {
|
||||
t2 = $[22];
|
||||
}
|
||||
t1 = visible.map(t2);
|
||||
$[5] = direction;
|
||||
$[6] = focusedIndex;
|
||||
$[7] = getKey;
|
||||
$[8] = renderItem;
|
||||
$[9] = total;
|
||||
$[10] = visible;
|
||||
$[11] = visibleCount;
|
||||
$[12] = windowStart;
|
||||
$[13] = t1;
|
||||
} else {
|
||||
t1 = $[13];
|
||||
}
|
||||
const rows = t1;
|
||||
const t2 = direction === "up" ? "column-reverse" : "column";
|
||||
let t3;
|
||||
if ($[23] !== rows || $[24] !== t2 || $[25] !== visibleCount) {
|
||||
t3 = <Box height={visibleCount} flexShrink={0} flexDirection={t2}>{rows}</Box>;
|
||||
$[23] = rows;
|
||||
$[24] = t2;
|
||||
$[25] = visibleCount;
|
||||
$[26] = t3;
|
||||
} else {
|
||||
t3 = $[26];
|
||||
}
|
||||
return t3;
|
||||
|
||||
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
|
||||
return (
|
||||
<ListItem
|
||||
key={getKey(item)}
|
||||
isFocused={isFocused}
|
||||
showScrollUp={direction === 'up' ? atHighEdge : atLowEdge}
|
||||
showScrollDown={direction === 'up' ? atLowEdge : atHighEdge}
|
||||
styled={false}
|
||||
>
|
||||
{renderItem(item, isFocused)}
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<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)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import Text from '../../ink/components/Text.js';
|
||||
import React from 'react'
|
||||
import Text from '../../ink/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,46 +35,24 @@ type Props = {
|
||||
* </Byline>
|
||||
* </Text>
|
||||
*/
|
||||
export function KeyboardShortcutHint(t0) {
|
||||
const $ = _c(9);
|
||||
const {
|
||||
shortcut,
|
||||
action,
|
||||
parens: t1,
|
||||
bold: t2
|
||||
} = t0;
|
||||
const parens = t1 === undefined ? false : t1;
|
||||
const bold = t2 === undefined ? false : t2;
|
||||
let t3;
|
||||
if ($[0] !== bold || $[1] !== shortcut) {
|
||||
t3 = bold ? <Text bold={true}>{shortcut}</Text> : shortcut;
|
||||
$[0] = bold;
|
||||
$[1] = shortcut;
|
||||
$[2] = t3;
|
||||
} else {
|
||||
t3 = $[2];
|
||||
}
|
||||
const shortcutText = t3;
|
||||
export function KeyboardShortcutHint({
|
||||
shortcut,
|
||||
action,
|
||||
parens = false,
|
||||
bold = false,
|
||||
}: Props): React.ReactNode {
|
||||
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
|
||||
|
||||
if (parens) {
|
||||
let t4;
|
||||
if ($[3] !== action || $[4] !== shortcutText) {
|
||||
t4 = <Text>({shortcutText} to {action})</Text>;
|
||||
$[3] = action;
|
||||
$[4] = shortcutText;
|
||||
$[5] = t4;
|
||||
} else {
|
||||
t4 = $[5];
|
||||
}
|
||||
return t4;
|
||||
return (
|
||||
<Text>
|
||||
({shortcutText} to {action})
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
let t4;
|
||||
if ($[6] !== action || $[7] !== shortcutText) {
|
||||
t4 = <Text>{shortcutText} to {action}</Text>;
|
||||
$[6] = action;
|
||||
$[7] = shortcutText;
|
||||
$[8] = t4;
|
||||
} else {
|
||||
t4 = $[8];
|
||||
}
|
||||
return t4;
|
||||
return (
|
||||
<Text>
|
||||
{shortcutText} to {action}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import figures from 'figures';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import figures from 'figures'
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js'
|
||||
import { Box, Text } from '../../ink.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).
|
||||
@@ -101,143 +101,88 @@ type ListItemProps = {
|
||||
* <Text color="claude">Custom styled content</Text>
|
||||
* </ListItem>
|
||||
*/
|
||||
export function ListItem(t0) {
|
||||
const $ = _c(32);
|
||||
const {
|
||||
isFocused,
|
||||
isSelected: t1,
|
||||
children,
|
||||
description,
|
||||
showScrollDown,
|
||||
showScrollUp,
|
||||
styled: t2,
|
||||
disabled: t3,
|
||||
declareCursor
|
||||
} = t0;
|
||||
const isSelected = t1 === undefined ? false : t1;
|
||||
const styled = t2 === undefined ? true : t2;
|
||||
const disabled = t3 === undefined ? false : t3;
|
||||
let t4;
|
||||
if ($[0] !== disabled || $[1] !== isFocused || $[2] !== showScrollDown || $[3] !== showScrollUp) {
|
||||
t4 = function renderIndicator() {
|
||||
if (disabled) {
|
||||
return <Text> </Text>;
|
||||
}
|
||||
if (isFocused) {
|
||||
return <Text color="suggestion">{figures.pointer}</Text>;
|
||||
}
|
||||
if (showScrollDown) {
|
||||
return <Text dimColor={true}>{figures.arrowDown}</Text>;
|
||||
}
|
||||
if (showScrollUp) {
|
||||
return <Text dimColor={true}>{figures.arrowUp}</Text>;
|
||||
}
|
||||
return <Text> </Text>;
|
||||
};
|
||||
$[0] = disabled;
|
||||
$[1] = isFocused;
|
||||
$[2] = showScrollDown;
|
||||
$[3] = showScrollUp;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
export function ListItem({
|
||||
isFocused,
|
||||
isSelected = false,
|
||||
children,
|
||||
description,
|
||||
showScrollDown,
|
||||
showScrollUp,
|
||||
styled = true,
|
||||
disabled = false,
|
||||
declareCursor,
|
||||
}: ListItemProps): React.ReactNode {
|
||||
// Determine which indicator to show
|
||||
function renderIndicator(): ReactNode {
|
||||
if (disabled) {
|
||||
return <Text> </Text>
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
return <Text color="suggestion">{figures.pointer}</Text>
|
||||
}
|
||||
|
||||
if (showScrollDown) {
|
||||
return <Text dimColor>{figures.arrowDown}</Text>
|
||||
}
|
||||
|
||||
if (showScrollUp) {
|
||||
return <Text dimColor>{figures.arrowUp}</Text>
|
||||
}
|
||||
|
||||
return <Text> </Text>
|
||||
}
|
||||
const renderIndicator = t4;
|
||||
let t5;
|
||||
if ($[5] !== disabled || $[6] !== isFocused || $[7] !== isSelected || $[8] !== styled) {
|
||||
const getTextColor = function getTextColor() {
|
||||
if (disabled) {
|
||||
return "inactive";
|
||||
}
|
||||
if (!styled) {
|
||||
return;
|
||||
}
|
||||
if (isSelected) {
|
||||
return "success";
|
||||
}
|
||||
if (isFocused) {
|
||||
return "suggestion";
|
||||
}
|
||||
};
|
||||
t5 = getTextColor();
|
||||
$[5] = disabled;
|
||||
$[6] = isFocused;
|
||||
$[7] = isSelected;
|
||||
$[8] = styled;
|
||||
$[9] = t5;
|
||||
} else {
|
||||
t5 = $[9];
|
||||
|
||||
// Determine text color based on state
|
||||
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
|
||||
if (disabled) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
if (!styled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
return 'suggestion'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
const textColor = t5;
|
||||
const t6 = isFocused && !disabled && declareCursor !== false;
|
||||
let t7;
|
||||
if ($[10] !== t6) {
|
||||
t7 = {
|
||||
line: 0,
|
||||
column: 0,
|
||||
active: t6
|
||||
};
|
||||
$[10] = t6;
|
||||
$[11] = t7;
|
||||
} else {
|
||||
t7 = $[11];
|
||||
}
|
||||
const cursorRef = useDeclaredCursor(t7);
|
||||
let t8;
|
||||
if ($[12] !== renderIndicator) {
|
||||
t8 = renderIndicator();
|
||||
$[12] = renderIndicator;
|
||||
$[13] = t8;
|
||||
} else {
|
||||
t8 = $[13];
|
||||
}
|
||||
let t9;
|
||||
if ($[14] !== children || $[15] !== disabled || $[16] !== styled || $[17] !== textColor) {
|
||||
t9 = styled ? <Text color={textColor} dimColor={disabled}>{children}</Text> : children;
|
||||
$[14] = children;
|
||||
$[15] = disabled;
|
||||
$[16] = styled;
|
||||
$[17] = textColor;
|
||||
$[18] = t9;
|
||||
} else {
|
||||
t9 = $[18];
|
||||
}
|
||||
let t10;
|
||||
if ($[19] !== disabled || $[20] !== isSelected) {
|
||||
t10 = isSelected && !disabled && <Text color="success">{figures.tick}</Text>;
|
||||
$[19] = disabled;
|
||||
$[20] = isSelected;
|
||||
$[21] = t10;
|
||||
} else {
|
||||
t10 = $[21];
|
||||
}
|
||||
let t11;
|
||||
if ($[22] !== t10 || $[23] !== t8 || $[24] !== t9) {
|
||||
t11 = <Box flexDirection="row" gap={1}>{t8}{t9}{t10}</Box>;
|
||||
$[22] = t10;
|
||||
$[23] = t8;
|
||||
$[24] = t9;
|
||||
$[25] = t11;
|
||||
} else {
|
||||
t11 = $[25];
|
||||
}
|
||||
let t12;
|
||||
if ($[26] !== description) {
|
||||
t12 = description && <Box paddingLeft={2}><Text color="inactive">{description}</Text></Box>;
|
||||
$[26] = description;
|
||||
$[27] = t12;
|
||||
} else {
|
||||
t12 = $[27];
|
||||
}
|
||||
let t13;
|
||||
if ($[28] !== cursorRef || $[29] !== t11 || $[30] !== t12) {
|
||||
t13 = <Box ref={cursorRef} flexDirection="column">{t11}{t12}</Box>;
|
||||
$[28] = cursorRef;
|
||||
$[29] = t11;
|
||||
$[30] = t12;
|
||||
$[31] = t13;
|
||||
} else {
|
||||
t13 = $[31];
|
||||
}
|
||||
return t13;
|
||||
|
||||
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
|
||||
// this Box, where the pointer renders.
|
||||
const cursorRef = useDeclaredCursor({
|
||||
line: 0,
|
||||
column: 0,
|
||||
active: isFocused && !disabled && declareCursor !== false,
|
||||
})
|
||||
|
||||
return (
|
||||
<Box ref={cursorRef} flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{renderIndicator()}
|
||||
{styled ? (
|
||||
<Text color={textColor} dimColor={disabled}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{isSelected && !disabled && <Text color="success">{figures.tick}</Text>}
|
||||
</Box>
|
||||
{description && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="inactive">{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { Spinner } from '../Spinner.js';
|
||||
import React from 'react'
|
||||
import { Box, Text } from '../../ink.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.
|
||||
@@ -45,49 +45,22 @@ type LoadingStateProps = {
|
||||
* subtitle="Fetching your Claude Code sessions..."
|
||||
* />
|
||||
*/
|
||||
export function LoadingState(t0) {
|
||||
const $ = _c(10);
|
||||
const {
|
||||
message,
|
||||
bold: t1,
|
||||
dimColor: t2,
|
||||
subtitle
|
||||
} = t0;
|
||||
const bold = t1 === undefined ? false : t1;
|
||||
const dimColor = t2 === undefined ? false : t2;
|
||||
let t3;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = <Spinner />;
|
||||
$[0] = t3;
|
||||
} else {
|
||||
t3 = $[0];
|
||||
}
|
||||
let t4;
|
||||
if ($[1] !== bold || $[2] !== dimColor || $[3] !== message) {
|
||||
t4 = <Box flexDirection="row">{t3}<Text bold={bold} dimColor={dimColor}>{" "}{message}</Text></Box>;
|
||||
$[1] = bold;
|
||||
$[2] = dimColor;
|
||||
$[3] = message;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
}
|
||||
let t5;
|
||||
if ($[5] !== subtitle) {
|
||||
t5 = subtitle && <Text dimColor={true}>{subtitle}</Text>;
|
||||
$[5] = subtitle;
|
||||
$[6] = t5;
|
||||
} else {
|
||||
t5 = $[6];
|
||||
}
|
||||
let t6;
|
||||
if ($[7] !== t4 || $[8] !== t5) {
|
||||
t6 = <Box flexDirection="column">{t4}{t5}</Box>;
|
||||
$[7] = t4;
|
||||
$[8] = t5;
|
||||
$[9] = t6;
|
||||
} else {
|
||||
t6 = $[9];
|
||||
}
|
||||
return t6;
|
||||
export function LoadingState({
|
||||
message,
|
||||
bold = false,
|
||||
dimColor = false,
|
||||
subtitle,
|
||||
}: LoadingStateProps): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Spinner />
|
||||
<Text bold={bold} dimColor={dimColor}>
|
||||
{' '}
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
{subtitle && <Text dimColor>{subtitle}</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { useIsInsideModal } from '../../context/modalContext.js';
|
||||
import { Box } from '../../ink.js';
|
||||
import type { Theme } from '../../utils/theme.js';
|
||||
import { Divider } from './Divider.js';
|
||||
import React from 'react'
|
||||
import { useIsInsideModal } from '../../context/modalContext.js'
|
||||
import { Box } from '../../ink.js'
|
||||
import type { Theme } from '../../utils/theme.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,
|
||||
@@ -30,47 +30,28 @@ type PaneProps = {
|
||||
* <Tabs title="Sandbox:">...</Tabs>
|
||||
* </Pane>
|
||||
*/
|
||||
export function Pane(t0) {
|
||||
const $ = _c(9);
|
||||
const {
|
||||
children,
|
||||
color
|
||||
} = t0;
|
||||
export function Pane({ children, color }: PaneProps): React.ReactNode {
|
||||
// When rendered inside FullscreenLayout's modal slot, its ▔ divider IS
|
||||
// the frame. Skip our own Divider (would double-frame) and the extra top
|
||||
// padding. This lets slash-command screens that wrap in Pane (e.g.
|
||||
// /model → ModelPicker) route through the modal slot unchanged.
|
||||
if (useIsInsideModal()) {
|
||||
let t1;
|
||||
if ($[0] !== children) {
|
||||
t1 = <Box flexDirection="column" paddingX={1} flexShrink={0}>{children}</Box>;
|
||||
$[0] = children;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
return t1;
|
||||
// flexShrink=0: the modal slot's absolute Box has no explicit height
|
||||
// (grows to fit, maxHeight cap). With flexGrow=1, re-renders cause
|
||||
// yoga to resolve this Box's height to 0 against the undetermined
|
||||
// parent — /permissions body blanks on Down arrow. See #23592.
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} flexShrink={0}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
let t1;
|
||||
if ($[2] !== color) {
|
||||
t1 = <Divider color={color} />;
|
||||
$[2] = color;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t1 = $[3];
|
||||
}
|
||||
let t2;
|
||||
if ($[4] !== children) {
|
||||
t2 = <Box flexDirection="column" paddingX={2}>{children}</Box>;
|
||||
$[4] = children;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t2 = $[5];
|
||||
}
|
||||
let t3;
|
||||
if ($[6] !== t1 || $[7] !== t2) {
|
||||
t3 = <Box flexDirection="column" paddingTop={1}>{t1}{t2}</Box>;
|
||||
$[6] = t1;
|
||||
$[7] = t2;
|
||||
$[8] = t3;
|
||||
} else {
|
||||
t3 = $[8];
|
||||
}
|
||||
return t3;
|
||||
return (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Divider color={color} />
|
||||
<Box flexDirection="column" paddingX={2}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +1,54 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { Text } from '../../ink.js';
|
||||
import type { Theme } from '../../utils/theme.js';
|
||||
import React from 'react'
|
||||
import { Text } from '../../ink.js'
|
||||
import type { Theme } from '../../utils/theme.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;
|
||||
};
|
||||
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
||||
export function ProgressBar(t0) {
|
||||
const $ = _c(13);
|
||||
const {
|
||||
ratio: inputRatio,
|
||||
width,
|
||||
fillColor,
|
||||
emptyColor
|
||||
} = t0;
|
||||
const ratio = Math.min(1, Math.max(0, inputRatio));
|
||||
const whole = Math.floor(ratio * width);
|
||||
let t1;
|
||||
if ($[0] !== whole) {
|
||||
t1 = BLOCKS[BLOCKS.length - 1].repeat(whole);
|
||||
$[0] = whole;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
let segments;
|
||||
if ($[2] !== ratio || $[3] !== t1 || $[4] !== whole || $[5] !== width) {
|
||||
segments = [t1];
|
||||
if (whole < width) {
|
||||
const remainder = ratio * width - whole;
|
||||
const middle = Math.floor(remainder * BLOCKS.length);
|
||||
segments.push(BLOCKS[middle]);
|
||||
const empty = width - whole - 1;
|
||||
if (empty > 0) {
|
||||
let t2;
|
||||
if ($[7] !== empty) {
|
||||
t2 = BLOCKS[0].repeat(empty);
|
||||
$[7] = empty;
|
||||
$[8] = t2;
|
||||
} else {
|
||||
t2 = $[8];
|
||||
}
|
||||
segments.push(t2);
|
||||
}
|
||||
}
|
||||
$[2] = ratio;
|
||||
$[3] = t1;
|
||||
$[4] = whole;
|
||||
$[5] = width;
|
||||
$[6] = segments;
|
||||
} else {
|
||||
segments = $[6];
|
||||
}
|
||||
const t2 = segments.join("");
|
||||
let t3;
|
||||
if ($[9] !== emptyColor || $[10] !== fillColor || $[11] !== t2) {
|
||||
t3 = <Text color={fillColor} backgroundColor={emptyColor}>{t2}</Text>;
|
||||
$[9] = emptyColor;
|
||||
$[10] = fillColor;
|
||||
$[11] = t2;
|
||||
$[12] = t3;
|
||||
} else {
|
||||
t3 = $[12];
|
||||
}
|
||||
return t3;
|
||||
emptyColor?: keyof Theme
|
||||
}
|
||||
|
||||
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)]
|
||||
if (whole < width) {
|
||||
const remainder = ratio * width - whole
|
||||
const middle = Math.floor(remainder * BLOCKS.length)
|
||||
segments.push(BLOCKS[middle]!)
|
||||
|
||||
const empty = width - whole - 1
|
||||
if (empty > 0) {
|
||||
segments.push(BLOCKS[0]!.repeat(empty))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={fillColor} backgroundColor={emptyColor}>
|
||||
{segments.join('')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +1,45 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js';
|
||||
import { Box, type DOMElement, measureElement } from '../../ink.js';
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { useTerminalViewport } from '../../ink/hooks/use-terminal-viewport.js'
|
||||
import { Box, type DOMElement, measureElement } from '../../ink.js'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
lock?: 'always' | 'offscreen';
|
||||
};
|
||||
export function Ratchet(t0) {
|
||||
const $ = _c(10);
|
||||
const {
|
||||
children,
|
||||
lock: t1
|
||||
} = t0;
|
||||
const lock = t1 === undefined ? "always" : t1;
|
||||
const [viewportRef, t2] = useTerminalViewport();
|
||||
const {
|
||||
isVisible
|
||||
} = t2;
|
||||
const {
|
||||
rows
|
||||
} = useTerminalSize();
|
||||
const innerRef = useRef(null);
|
||||
const maxHeight = useRef(0);
|
||||
const [minHeight, setMinHeight] = useState(0);
|
||||
let t3;
|
||||
if ($[0] !== viewportRef) {
|
||||
t3 = el => {
|
||||
viewportRef(el);
|
||||
};
|
||||
$[0] = viewportRef;
|
||||
$[1] = t3;
|
||||
} else {
|
||||
t3 = $[1];
|
||||
}
|
||||
const outerRef = t3;
|
||||
const engaged = lock === "always" || !isVisible;
|
||||
let t4;
|
||||
if ($[2] !== rows) {
|
||||
t4 = () => {
|
||||
if (!innerRef.current) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
height
|
||||
} = measureElement(innerRef.current);
|
||||
if (height > maxHeight.current) {
|
||||
maxHeight.current = Math.min(height, rows);
|
||||
setMinHeight(maxHeight.current);
|
||||
}
|
||||
};
|
||||
$[2] = rows;
|
||||
$[3] = t4;
|
||||
} else {
|
||||
t4 = $[3];
|
||||
}
|
||||
useLayoutEffect(t4);
|
||||
const t5 = engaged ? minHeight : undefined;
|
||||
let t6;
|
||||
if ($[4] !== children) {
|
||||
t6 = <Box ref={innerRef} flexDirection="column">{children}</Box>;
|
||||
$[4] = children;
|
||||
$[5] = t6;
|
||||
} else {
|
||||
t6 = $[5];
|
||||
}
|
||||
let t7;
|
||||
if ($[6] !== outerRef || $[7] !== t5 || $[8] !== t6) {
|
||||
t7 = <Box minHeight={t5} ref={outerRef}>{t6}</Box>;
|
||||
$[6] = outerRef;
|
||||
$[7] = t5;
|
||||
$[8] = t6;
|
||||
$[9] = t7;
|
||||
} else {
|
||||
t7 = $[9];
|
||||
}
|
||||
return t7;
|
||||
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 outerRef = useCallback(
|
||||
(el: DOMElement | null) => {
|
||||
viewportRef(el)
|
||||
},
|
||||
[viewportRef],
|
||||
)
|
||||
|
||||
const engaged = lock === 'always' || !isVisible
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!innerRef.current) {
|
||||
return
|
||||
}
|
||||
const { height } = measureElement(innerRef.current)
|
||||
if (height > maxHeight.current) {
|
||||
maxHeight.current = Math.min(height, rows)
|
||||
setMinHeight(maxHeight.current)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box minHeight={engaged ? minHeight : undefined} ref={outerRef}>
|
||||
<Box ref={innerRef} flexDirection="column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { Text } from '../../ink.js';
|
||||
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading';
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { Text } from '../../ink.js'
|
||||
|
||||
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The status to display. Determines both the icon and color.
|
||||
@@ -14,42 +15,28 @@ 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;
|
||||
};
|
||||
const STATUS_CONFIG: Record<Status, {
|
||||
icon: string;
|
||||
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined;
|
||||
}> = {
|
||||
success: {
|
||||
icon: figures.tick,
|
||||
color: 'success'
|
||||
},
|
||||
error: {
|
||||
icon: figures.cross,
|
||||
color: 'error'
|
||||
},
|
||||
warning: {
|
||||
icon: figures.warning,
|
||||
color: 'warning'
|
||||
},
|
||||
info: {
|
||||
icon: figures.info,
|
||||
color: 'suggestion'
|
||||
},
|
||||
pending: {
|
||||
icon: figures.circle,
|
||||
color: undefined
|
||||
},
|
||||
loading: {
|
||||
icon: '…',
|
||||
color: undefined
|
||||
withSpace?: boolean
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
Status,
|
||||
{
|
||||
icon: string
|
||||
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined
|
||||
}
|
||||
};
|
||||
> = {
|
||||
success: { icon: figures.tick, color: 'success' },
|
||||
error: { icon: figures.cross, color: 'error' },
|
||||
warning: { icon: figures.warning, color: 'warning' },
|
||||
info: { icon: figures.info, color: 'suggestion' },
|
||||
pending: { icon: figures.circle, color: undefined },
|
||||
loading: { icon: '…', color: undefined },
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a status indicator icon with appropriate color.
|
||||
@@ -69,26 +56,16 @@ const STATUS_CONFIG: Record<Status, {
|
||||
* Waiting for response
|
||||
* </Text>
|
||||
*/
|
||||
export function StatusIcon(t0) {
|
||||
const $ = _c(5);
|
||||
const {
|
||||
status,
|
||||
withSpace: t1
|
||||
} = t0;
|
||||
const withSpace = t1 === undefined ? false : t1;
|
||||
const config = STATUS_CONFIG[status];
|
||||
const t2 = !config.color;
|
||||
const t3 = withSpace && " ";
|
||||
let t4;
|
||||
if ($[0] !== config.color || $[1] !== config.icon || $[2] !== t2 || $[3] !== t3) {
|
||||
t4 = <Text color={config.color} dimColor={t2}>{config.icon}{t3}</Text>;
|
||||
$[0] = config.color;
|
||||
$[1] = config.icon;
|
||||
$[2] = t2;
|
||||
$[3] = t3;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
}
|
||||
return t4;
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useIsInsideModal, useModalScrollRef } from '../../context/modalContext.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import ScrollBox from '../../ink/components/ScrollBox.js';
|
||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
||||
import { stringWidth } from '../../ink/stringWidth.js';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import type { Theme } from '../../utils/theme.js';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useIsInsideModal,
|
||||
useModalScrollRef,
|
||||
} from '../../context/modalContext.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import ScrollBox from '../../ink/components/ScrollBox.js'
|
||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'
|
||||
import { stringWidth } from '../../ink/stringWidth.js'
|
||||
import { Box, Text } from '../../ink.js'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import type { Theme } from '../../utils/theme.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 —
|
||||
@@ -31,28 +40,30 @@ 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,
|
||||
width: undefined,
|
||||
@@ -61,236 +72,248 @@ const TabsContext = createContext<TabsContextValue>({
|
||||
headerFocused: false,
|
||||
focusHeader: () => {},
|
||||
blurHeader: () => {},
|
||||
registerOptIn: () => () => {}
|
||||
});
|
||||
export function Tabs(t0) {
|
||||
const $ = _c(25);
|
||||
const {
|
||||
title,
|
||||
color,
|
||||
defaultTab,
|
||||
children,
|
||||
hidden,
|
||||
useFullWidth,
|
||||
selectedTab: controlledSelectedTab,
|
||||
onTabChange,
|
||||
banner,
|
||||
disableNavigation,
|
||||
initialHeaderFocused: t1,
|
||||
contentHeight,
|
||||
navFromContent: t2
|
||||
} = t0;
|
||||
const initialHeaderFocused = t1 === undefined ? true : t1;
|
||||
const navFromContent = t2 === undefined ? false : t2;
|
||||
const {
|
||||
columns: terminalWidth
|
||||
} = useTerminalSize();
|
||||
const tabs = children.map(_temp);
|
||||
const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0;
|
||||
const isControlled = controlledSelectedTab !== undefined;
|
||||
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0);
|
||||
const controlledTabIndex = isControlled ? tabs.findIndex(tab_0 => tab_0[0] === controlledSelectedTab) : -1;
|
||||
const selectedTabIndex = isControlled ? controlledTabIndex !== -1 ? controlledTabIndex : 0 : internalSelectedTab;
|
||||
const modalScrollRef = useModalScrollRef();
|
||||
const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused);
|
||||
let t3;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = () => setHeaderFocused(true);
|
||||
$[0] = t3;
|
||||
} else {
|
||||
t3 = $[0];
|
||||
}
|
||||
const focusHeader = t3;
|
||||
let t4;
|
||||
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t4 = () => setHeaderFocused(false);
|
||||
$[1] = t4;
|
||||
} else {
|
||||
t4 = $[1];
|
||||
}
|
||||
const blurHeader = t4;
|
||||
const [optInCount, setOptInCount] = useState(0);
|
||||
let t5;
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t5 = () => {
|
||||
setOptInCount(_temp2);
|
||||
return () => setOptInCount(_temp3);
|
||||
};
|
||||
$[2] = t5;
|
||||
} else {
|
||||
t5 = $[2];
|
||||
}
|
||||
const registerOptIn = t5;
|
||||
const optedIn = optInCount > 0;
|
||||
const handleTabChange = offset => {
|
||||
const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length;
|
||||
const newTabId = tabs[newIndex]?.[0];
|
||||
registerOptIn: () => () => {},
|
||||
})
|
||||
|
||||
export function Tabs({
|
||||
title,
|
||||
color,
|
||||
defaultTab,
|
||||
children,
|
||||
hidden,
|
||||
useFullWidth,
|
||||
selectedTab: controlledSelectedTab,
|
||||
onTabChange,
|
||||
banner,
|
||||
disableNavigation,
|
||||
initialHeaderFocused = true,
|
||||
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
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
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 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), [])
|
||||
// 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 registerOptIn = useCallback(() => {
|
||||
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]
|
||||
|
||||
if (isControlled && onTabChange && newTabId) {
|
||||
onTabChange(newTabId);
|
||||
onTabChange(newTabId)
|
||||
} else {
|
||||
setInternalSelectedTab(newIndex);
|
||||
setInternalSelectedTab(newIndex)
|
||||
}
|
||||
setHeaderFocused(true);
|
||||
};
|
||||
const t6 = !hidden && !disableNavigation && headerFocused;
|
||||
let t7;
|
||||
if ($[3] !== t6) {
|
||||
t7 = {
|
||||
context: "Tabs",
|
||||
isActive: t6
|
||||
};
|
||||
$[3] = t6;
|
||||
$[4] = t7;
|
||||
} else {
|
||||
t7 = $[4];
|
||||
// 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)
|
||||
}
|
||||
useKeybindings({
|
||||
"tabs:next": () => handleTabChange(1),
|
||||
"tabs:previous": () => handleTabChange(-1)
|
||||
}, t7);
|
||||
let t8;
|
||||
if ($[5] !== headerFocused || $[6] !== hidden || $[7] !== optedIn) {
|
||||
t8 = e => {
|
||||
if (!headerFocused || !optedIn || hidden) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "down") {
|
||||
e.preventDefault();
|
||||
setHeaderFocused(false);
|
||||
}
|
||||
};
|
||||
$[5] = headerFocused;
|
||||
$[6] = hidden;
|
||||
$[7] = optedIn;
|
||||
$[8] = t8;
|
||||
} else {
|
||||
t8 = $[8];
|
||||
}
|
||||
const handleKeyDown = t8;
|
||||
const t9 = navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation;
|
||||
let t10;
|
||||
if ($[9] !== t9) {
|
||||
t10 = {
|
||||
context: "Tabs",
|
||||
isActive: t9
|
||||
};
|
||||
$[9] = t9;
|
||||
$[10] = t10;
|
||||
} else {
|
||||
t10 = $[10];
|
||||
}
|
||||
useKeybindings({
|
||||
"tabs:next": () => {
|
||||
handleTabChange(1);
|
||||
setHeaderFocused(true);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'tabs:next': () => handleTabChange(1),
|
||||
'tabs:previous': () => handleTabChange(-1),
|
||||
},
|
||||
"tabs:previous": () => {
|
||||
handleTabChange(-1);
|
||||
setHeaderFocused(true);
|
||||
{
|
||||
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 (e.key === 'down') {
|
||||
e.preventDefault()
|
||||
setHeaderFocused(false)
|
||||
}
|
||||
}, t10);
|
||||
const titleWidth = title ? stringWidth(title) + 1 : 0;
|
||||
const tabsWidth = tabs.reduce(_temp4, 0);
|
||||
const usedWidth = titleWidth + tabsWidth;
|
||||
const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0;
|
||||
const contentWidth = useFullWidth ? terminalWidth : undefined;
|
||||
const T0 = Box;
|
||||
const t11 = "column";
|
||||
const t12 = 0;
|
||||
const t13 = true;
|
||||
const t14 = modalScrollRef ? 0 : undefined;
|
||||
const t15 = !hidden && <Box flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>{title !== undefined && <Text bold={true} color={color}>{title}</Text>}{tabs.map((t16, i) => {
|
||||
const [id, title_0] = t16;
|
||||
const isCurrent = selectedTabIndex === i;
|
||||
const hasColorCursor = color && isCurrent && headerFocused;
|
||||
return <Text key={id} backgroundColor={hasColorCursor ? color : undefined} color={hasColorCursor ? "inverseText" : undefined} inverse={isCurrent && !hasColorCursor} bold={isCurrent}>{" "}{title_0}{" "}</Text>;
|
||||
})}{spacerWidth > 0 && <Text>{" ".repeat(spacerWidth)}</Text>}</Box>;
|
||||
let t17;
|
||||
if ($[11] !== children || $[12] !== contentHeight || $[13] !== contentWidth || $[14] !== hidden || $[15] !== modalScrollRef || $[16] !== selectedTabIndex) {
|
||||
t17 = modalScrollRef ? <Box width={contentWidth} marginTop={hidden ? 0 : 1} flexShrink={0}><ScrollBox key={selectedTabIndex} ref={modalScrollRef} flexDirection="column" flexShrink={0}>{children}</ScrollBox></Box> : <Box width={contentWidth} marginTop={hidden ? 0 : 1} height={contentHeight} overflowY={contentHeight !== undefined ? "hidden" : undefined}>{children}</Box>;
|
||||
$[11] = children;
|
||||
$[12] = contentHeight;
|
||||
$[13] = contentWidth;
|
||||
$[14] = hidden;
|
||||
$[15] = modalScrollRef;
|
||||
$[16] = selectedTabIndex;
|
||||
$[17] = t17;
|
||||
} else {
|
||||
t17 = $[17];
|
||||
}
|
||||
let t18;
|
||||
if ($[18] !== T0 || $[19] !== banner || $[20] !== handleKeyDown || $[21] !== t14 || $[22] !== t15 || $[23] !== t17) {
|
||||
t18 = <T0 flexDirection={t11} tabIndex={t12} autoFocus={t13} onKeyDown={handleKeyDown} flexShrink={t14}>{t15}{banner}{t17}</T0>;
|
||||
$[18] = T0;
|
||||
$[19] = banner;
|
||||
$[20] = handleKeyDown;
|
||||
$[21] = t14;
|
||||
$[22] = t15;
|
||||
$[23] = t17;
|
||||
$[24] = t18;
|
||||
} else {
|
||||
t18 = $[24];
|
||||
}
|
||||
return <TabsContext.Provider value={{
|
||||
selectedTab: tabs[selectedTabIndex][0],
|
||||
width: contentWidth,
|
||||
headerFocused,
|
||||
focusHeader,
|
||||
blurHeader,
|
||||
registerOptIn
|
||||
}}>{t18}</TabsContext.Provider>;
|
||||
}
|
||||
function _temp4(sum, t0) {
|
||||
const [, tabTitle] = t0;
|
||||
return sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1;
|
||||
}
|
||||
function _temp3(n_0) {
|
||||
return n_0 - 1;
|
||||
}
|
||||
function _temp2(n) {
|
||||
return n + 1;
|
||||
}
|
||||
function _temp(child) {
|
||||
return [child.props.id ?? child.props.title, child.props.title];
|
||||
|
||||
// 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)
|
||||
},
|
||||
'tabs:previous': () => {
|
||||
handleTabChange(-1)
|
||||
setHeaderFocused(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Tabs',
|
||||
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 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 contentWidth = useFullWidth ? terminalWidth : undefined
|
||||
|
||||
return (
|
||||
<TabsContext.Provider
|
||||
value={{
|
||||
selectedTab: tabs[selectedTabIndex]![0],
|
||||
width: contentWidth,
|
||||
headerFocused,
|
||||
focusHeader,
|
||||
blurHeader,
|
||||
registerOptIn,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
// flexShrink=0 inside modal slot — the modal's absolute Box has no
|
||||
// explicit height (grows to fit, maxHeight cap), so flexGrow=1 here
|
||||
// resolves to 0 on re-render and the body blanks on Down arrow.
|
||||
// See #23592. Outside modal, leave layout alone.
|
||||
flexShrink={modalScrollRef ? 0 : undefined}
|
||||
>
|
||||
{!hidden && (
|
||||
<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
|
||||
return (
|
||||
<Text
|
||||
key={id}
|
||||
backgroundColor={hasColorCursor ? color : undefined}
|
||||
color={hasColorCursor ? 'inverseText' : undefined}
|
||||
inverse={isCurrent && !hasColorCursor}
|
||||
bold={isCurrent}
|
||||
>
|
||||
{' '}
|
||||
{title}{' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{spacerWidth > 0 && <Text>{' '.repeat(spacerWidth)}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
{banner}
|
||||
{modalScrollRef ? (
|
||||
// Inside the modal slot: own the ScrollBox here so the tabs
|
||||
// header row above sits OUTSIDE the scroll area — it can never
|
||||
// scroll off. The ref reaches REPL's ScrollKeybindingHandler via
|
||||
// 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}
|
||||
>
|
||||
{children}
|
||||
</ScrollBox>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
width={contentWidth}
|
||||
marginTop={hidden ? 0 : 1}
|
||||
height={contentHeight}
|
||||
overflowY={contentHeight !== undefined ? 'hidden' : undefined}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type TabProps = {
|
||||
title: string;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export function Tab(t0) {
|
||||
const $ = _c(4);
|
||||
const {
|
||||
title,
|
||||
id,
|
||||
children
|
||||
} = t0;
|
||||
const {
|
||||
selectedTab,
|
||||
width
|
||||
} = useContext(TabsContext);
|
||||
const insideModal = useIsInsideModal();
|
||||
if (selectedTab !== (id ?? title)) {
|
||||
return null;
|
||||
}
|
||||
const t1 = insideModal ? 0 : undefined;
|
||||
let t2;
|
||||
if ($[0] !== children || $[1] !== t1 || $[2] !== width) {
|
||||
t2 = <Box width={width} flexShrink={t1}>{children}</Box>;
|
||||
$[0] = children;
|
||||
$[1] = t1;
|
||||
$[2] = width;
|
||||
$[3] = t2;
|
||||
} else {
|
||||
t2 = $[3];
|
||||
}
|
||||
return t2;
|
||||
title: string
|
||||
id?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
export function useTabsWidth() {
|
||||
const {
|
||||
width
|
||||
} = useContext(TabsContext);
|
||||
return width;
|
||||
|
||||
export function Tab({ title, id, children }: TabProps): React.ReactNode {
|
||||
const { selectedTab, width } = useContext(TabsContext)
|
||||
const insideModal = useIsInsideModal()
|
||||
if (selectedTab !== (id ?? title)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={width} flexShrink={insideModal ? 0 : undefined}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTabsWidth(): number | undefined {
|
||||
const { width } = useContext(TabsContext)
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,36 +327,13 @@ export function useTabsWidth() {
|
||||
* no onUpFromFirstItem to recover. Split the component so the hook only runs
|
||||
* when the Select renders.
|
||||
*/
|
||||
export function useTabHeaderFocus() {
|
||||
const $ = _c(6);
|
||||
const {
|
||||
headerFocused,
|
||||
focusHeader,
|
||||
blurHeader,
|
||||
registerOptIn
|
||||
} = useContext(TabsContext);
|
||||
let t0;
|
||||
if ($[0] !== registerOptIn) {
|
||||
t0 = [registerOptIn];
|
||||
$[0] = registerOptIn;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
useEffect(registerOptIn, t0);
|
||||
let t1;
|
||||
if ($[2] !== blurHeader || $[3] !== focusHeader || $[4] !== headerFocused) {
|
||||
t1 = {
|
||||
headerFocused,
|
||||
focusHeader,
|
||||
blurHeader
|
||||
};
|
||||
$[2] = blurHeader;
|
||||
$[3] = focusHeader;
|
||||
$[4] = headerFocused;
|
||||
$[5] = t1;
|
||||
} else {
|
||||
t1 = $[5];
|
||||
}
|
||||
return t1;
|
||||
export function useTabHeaderFocus(): {
|
||||
headerFocused: boolean
|
||||
focusHeader: () => void
|
||||
blurHeader: () => void
|
||||
} {
|
||||
const { headerFocused, focusHeader, blurHeader, registerOptIn } =
|
||||
useContext(TabsContext)
|
||||
useEffect(registerOptIn, [registerOptIn])
|
||||
return { headerFocused, focusHeader, blurHeader }
|
||||
}
|
||||
|
||||
@@ -1,169 +1,160 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { feature } from 'bun:bundle';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import useStdin from '../../ink/hooks/use-stdin.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { getSystemThemeName, type SystemTheme } from '../../utils/systemTheme.js';
|
||||
import type { ThemeName, ThemeSetting } from '../../utils/theme.js';
|
||||
import { feature } from 'bun:bundle'
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import useStdin from '../../ink/hooks/use-stdin.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import {
|
||||
getSystemThemeName,
|
||||
type SystemTheme,
|
||||
} from '../../utils/systemTheme.js'
|
||||
import type { ThemeName, ThemeSetting } from '../../utils/theme.js'
|
||||
|
||||
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,
|
||||
setThemeSetting: () => {},
|
||||
setPreviewTheme: () => {},
|
||||
savePreview: () => {},
|
||||
cancelPreview: () => {},
|
||||
currentTheme: DEFAULT_THEME
|
||||
});
|
||||
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 getGlobalConfig().theme;
|
||||
return getGlobalConfig().theme
|
||||
}
|
||||
|
||||
function defaultSaveTheme(setting: ThemeSetting): void {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
theme: setting
|
||||
}));
|
||||
saveGlobalConfig(current => ({ ...current, theme: setting }))
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
initialState,
|
||||
onThemeSave = defaultSaveTheme
|
||||
onThemeSave = defaultSaveTheme,
|
||||
}: Props) {
|
||||
const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme);
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null);
|
||||
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');
|
||||
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 {
|
||||
internal_querier
|
||||
} = useStdin();
|
||||
const activeSetting = previewTheme ?? themeSetting
|
||||
|
||||
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]);
|
||||
const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting;
|
||||
const value = useMemo<ThemeContextValue>(() => ({
|
||||
themeSetting,
|
||||
setThemeSetting: (newSetting: ThemeSetting) => {
|
||||
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());
|
||||
}
|
||||
onThemeSave?.(newSetting);
|
||||
},
|
||||
setPreviewTheme: (newSetting_0: ThemeSetting) => {
|
||||
setPreviewTheme(newSetting_0);
|
||||
if (newSetting_0 === 'auto') {
|
||||
setSystemTheme(getSystemThemeName());
|
||||
}
|
||||
},
|
||||
savePreview: () => {
|
||||
if (previewTheme !== null) {
|
||||
setThemeSetting(previewTheme);
|
||||
setPreviewTheme(null);
|
||||
onThemeSave?.(previewTheme);
|
||||
}
|
||||
},
|
||||
cancelPreview: () => {
|
||||
if (previewTheme !== null) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
},
|
||||
currentTheme
|
||||
}), [themeSetting, previewTheme, currentTheme, onThemeSave]);
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}, [activeSetting, internal_querier])
|
||||
|
||||
const currentTheme: ThemeName =
|
||||
activeSetting === 'auto' ? systemTheme : activeSetting
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
themeSetting,
|
||||
setThemeSetting: (newSetting: ThemeSetting) => {
|
||||
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())
|
||||
}
|
||||
onThemeSave?.(newSetting)
|
||||
},
|
||||
setPreviewTheme: (newSetting: ThemeSetting) => {
|
||||
setPreviewTheme(newSetting)
|
||||
if (newSetting === 'auto') {
|
||||
setSystemTheme(getSystemThemeName())
|
||||
}
|
||||
},
|
||||
savePreview: () => {
|
||||
if (previewTheme !== null) {
|
||||
setThemeSetting(previewTheme)
|
||||
setPreviewTheme(null)
|
||||
onThemeSave?.(previewTheme)
|
||||
}
|
||||
},
|
||||
cancelPreview: () => {
|
||||
if (previewTheme !== null) {
|
||||
setPreviewTheme(null)
|
||||
}
|
||||
},
|
||||
currentTheme,
|
||||
}),
|
||||
[themeSetting, previewTheme, currentTheme, onThemeSave],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved theme for rendering (never 'auto') and a setter that
|
||||
* accepts any ThemeSetting (including 'auto').
|
||||
*/
|
||||
export function useTheme() {
|
||||
const $ = _c(3);
|
||||
const {
|
||||
currentTheme,
|
||||
setThemeSetting
|
||||
} = useContext(ThemeContext);
|
||||
let t0;
|
||||
if ($[0] !== currentTheme || $[1] !== setThemeSetting) {
|
||||
t0 = [currentTheme, setThemeSetting];
|
||||
$[0] = currentTheme;
|
||||
$[1] = setThemeSetting;
|
||||
$[2] = t0;
|
||||
} else {
|
||||
t0 = $[2];
|
||||
}
|
||||
return t0;
|
||||
export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] {
|
||||
const { currentTheme, setThemeSetting } = useContext(ThemeContext)
|
||||
return [currentTheme, setThemeSetting]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw theme setting as stored in config. Use this in UI that
|
||||
* needs to show 'auto' as a distinct choice (e.g., ThemePicker).
|
||||
*/
|
||||
export function useThemeSetting() {
|
||||
return useContext(ThemeContext).themeSetting;
|
||||
export function useThemeSetting(): ThemeSetting {
|
||||
return useContext(ThemeContext).themeSetting
|
||||
}
|
||||
|
||||
export function usePreviewTheme() {
|
||||
const $ = _c(4);
|
||||
const {
|
||||
setPreviewTheme,
|
||||
savePreview,
|
||||
cancelPreview
|
||||
} = useContext(ThemeContext);
|
||||
let t0;
|
||||
if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) {
|
||||
t0 = {
|
||||
setPreviewTheme,
|
||||
savePreview,
|
||||
cancelPreview
|
||||
};
|
||||
$[0] = cancelPreview;
|
||||
$[1] = savePreview;
|
||||
$[2] = setPreviewTheme;
|
||||
$[3] = t0;
|
||||
} else {
|
||||
t0 = $[3];
|
||||
}
|
||||
return t0;
|
||||
const { setPreviewTheme, savePreview, cancelPreview } =
|
||||
useContext(ThemeContext)
|
||||
return { setPreviewTheme, savePreview, cancelPreview }
|
||||
}
|
||||
|
||||
@@ -1,155 +1,112 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { type PropsWithChildren, type Ref } from 'react';
|
||||
import Box from '../../ink/components/Box.js';
|
||||
import type { DOMElement } from '../../ink/dom.js';
|
||||
import type { ClickEvent } from '../../ink/events/click-event.js';
|
||||
import type { FocusEvent } from '../../ink/events/focus-event.js';
|
||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
|
||||
import type { Color, Styles } from '../../ink/styles.js';
|
||||
import { getTheme, type Theme } from '../../utils/theme.js';
|
||||
import { useTheme } from './ThemeProvider.js';
|
||||
import React, { type PropsWithChildren, type Ref } from 'react'
|
||||
import Box from '../../ink/components/Box.js'
|
||||
import type { DOMElement } from '../../ink/dom.js'
|
||||
import type { ClickEvent } from '../../ink/events/click-event.js'
|
||||
import type { FocusEvent } from '../../ink/events/focus-event.js'
|
||||
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'
|
||||
import type { Color, Styles } from '../../ink/styles.js'
|
||||
import { getTheme, type Theme } from '../../utils/theme.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<Styles, 'textWrap' | 'borderColor' | 'borderTopColor' | 'borderBottomColor' | '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;
|
||||
};
|
||||
type BaseStylesWithoutColors = Omit<
|
||||
Styles,
|
||||
| 'textWrap'
|
||||
| 'borderColor'
|
||||
| 'borderTopColor'
|
||||
| 'borderBottomColor'
|
||||
| '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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware Box component that resolves theme color keys to raw colors.
|
||||
* This wraps the base Box component with theme resolution for border colors.
|
||||
*/
|
||||
function ThemedBox(t0) {
|
||||
const $ = _c(33);
|
||||
let backgroundColor;
|
||||
let borderBottomColor;
|
||||
let borderColor;
|
||||
let borderLeftColor;
|
||||
let borderRightColor;
|
||||
let borderTopColor;
|
||||
let children;
|
||||
let ref;
|
||||
let rest;
|
||||
if ($[0] !== t0) {
|
||||
({
|
||||
borderColor,
|
||||
borderTopColor,
|
||||
borderBottomColor,
|
||||
borderLeftColor,
|
||||
borderRightColor,
|
||||
backgroundColor,
|
||||
children,
|
||||
ref,
|
||||
...rest
|
||||
} = t0);
|
||||
$[0] = t0;
|
||||
$[1] = backgroundColor;
|
||||
$[2] = borderBottomColor;
|
||||
$[3] = borderColor;
|
||||
$[4] = borderLeftColor;
|
||||
$[5] = borderRightColor;
|
||||
$[6] = borderTopColor;
|
||||
$[7] = children;
|
||||
$[8] = ref;
|
||||
$[9] = rest;
|
||||
} else {
|
||||
backgroundColor = $[1];
|
||||
borderBottomColor = $[2];
|
||||
borderColor = $[3];
|
||||
borderLeftColor = $[4];
|
||||
borderRightColor = $[5];
|
||||
borderTopColor = $[6];
|
||||
children = $[7];
|
||||
ref = $[8];
|
||||
rest = $[9];
|
||||
}
|
||||
const [themeName] = useTheme();
|
||||
let resolvedBorderBottomColor;
|
||||
let resolvedBorderColor;
|
||||
let resolvedBorderLeftColor;
|
||||
let resolvedBorderRightColor;
|
||||
let resolvedBorderTopColor;
|
||||
let t1;
|
||||
if ($[10] !== backgroundColor || $[11] !== borderBottomColor || $[12] !== borderColor || $[13] !== borderLeftColor || $[14] !== borderRightColor || $[15] !== borderTopColor || $[16] !== themeName) {
|
||||
const theme = getTheme(themeName);
|
||||
resolvedBorderColor = resolveColor(borderColor, theme);
|
||||
resolvedBorderTopColor = resolveColor(borderTopColor, theme);
|
||||
resolvedBorderBottomColor = resolveColor(borderBottomColor, theme);
|
||||
resolvedBorderLeftColor = resolveColor(borderLeftColor, theme);
|
||||
resolvedBorderRightColor = resolveColor(borderRightColor, theme);
|
||||
t1 = resolveColor(backgroundColor, theme);
|
||||
$[10] = backgroundColor;
|
||||
$[11] = borderBottomColor;
|
||||
$[12] = borderColor;
|
||||
$[13] = borderLeftColor;
|
||||
$[14] = borderRightColor;
|
||||
$[15] = borderTopColor;
|
||||
$[16] = themeName;
|
||||
$[17] = resolvedBorderBottomColor;
|
||||
$[18] = resolvedBorderColor;
|
||||
$[19] = resolvedBorderLeftColor;
|
||||
$[20] = resolvedBorderRightColor;
|
||||
$[21] = resolvedBorderTopColor;
|
||||
$[22] = t1;
|
||||
} else {
|
||||
resolvedBorderBottomColor = $[17];
|
||||
resolvedBorderColor = $[18];
|
||||
resolvedBorderLeftColor = $[19];
|
||||
resolvedBorderRightColor = $[20];
|
||||
resolvedBorderTopColor = $[21];
|
||||
t1 = $[22];
|
||||
}
|
||||
const resolvedBackgroundColor = t1;
|
||||
let t2;
|
||||
if ($[23] !== children || $[24] !== ref || $[25] !== resolvedBackgroundColor || $[26] !== resolvedBorderBottomColor || $[27] !== resolvedBorderColor || $[28] !== resolvedBorderLeftColor || $[29] !== resolvedBorderRightColor || $[30] !== resolvedBorderTopColor || $[31] !== rest) {
|
||||
t2 = <Box ref={ref} borderColor={resolvedBorderColor} borderTopColor={resolvedBorderTopColor} borderBottomColor={resolvedBorderBottomColor} borderLeftColor={resolvedBorderLeftColor} borderRightColor={resolvedBorderRightColor} backgroundColor={resolvedBackgroundColor} {...rest}>{children}</Box>;
|
||||
$[23] = children;
|
||||
$[24] = ref;
|
||||
$[25] = resolvedBackgroundColor;
|
||||
$[26] = resolvedBorderBottomColor;
|
||||
$[27] = resolvedBorderColor;
|
||||
$[28] = resolvedBorderLeftColor;
|
||||
$[29] = resolvedBorderRightColor;
|
||||
$[30] = resolvedBorderTopColor;
|
||||
$[31] = rest;
|
||||
$[32] = t2;
|
||||
} else {
|
||||
t2 = $[32];
|
||||
}
|
||||
return t2;
|
||||
function ThemedBox({
|
||||
borderColor,
|
||||
borderTopColor,
|
||||
borderBottomColor,
|
||||
borderLeftColor,
|
||||
borderRightColor,
|
||||
backgroundColor,
|
||||
children,
|
||||
ref,
|
||||
...rest
|
||||
}: PropsWithChildren<Props>): React.ReactNode {
|
||||
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)
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
borderColor={resolvedBorderColor}
|
||||
borderTopColor={resolvedBorderTopColor}
|
||||
borderBottomColor={resolvedBorderBottomColor}
|
||||
borderLeftColor={resolvedBorderLeftColor}
|
||||
borderRightColor={resolvedBorderRightColor}
|
||||
backgroundColor={resolvedBackgroundColor}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
export default ThemedBox;
|
||||
|
||||
export default ThemedBox
|
||||
|
||||
@@ -1,123 +1,132 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import Text from '../../ink/components/Text.js';
|
||||
import type { Color, Styles } from '../../ink/styles.js';
|
||||
import { getTheme, type Theme } from '../../utils/theme.js';
|
||||
import { useTheme } from './ThemeProvider.js';
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { useContext } from 'react'
|
||||
import Text from '../../ink/components/Text.js'
|
||||
import type { Color, Styles } from '../../ink/styles.js'
|
||||
import { getTheme, type Theme } from '../../utils/theme.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 children?: ReactNode;
|
||||
};
|
||||
readonly wrap?: Styles['textWrap']
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware Text component that resolves theme color keys to raw colors.
|
||||
* This wraps the base Text component with theme resolution.
|
||||
*/
|
||||
export default function ThemedText(t0) {
|
||||
const $ = _c(10);
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
dimColor: t1,
|
||||
bold: t2,
|
||||
italic: t3,
|
||||
underline: t4,
|
||||
strikethrough: t5,
|
||||
inverse: t6,
|
||||
wrap: t7,
|
||||
children
|
||||
} = t0;
|
||||
const dimColor = t1 === undefined ? false : t1;
|
||||
const bold = t2 === undefined ? false : t2;
|
||||
const italic = t3 === undefined ? false : t3;
|
||||
const underline = t4 === undefined ? false : t4;
|
||||
const strikethrough = t5 === undefined ? false : t5;
|
||||
const inverse = t6 === undefined ? false : t6;
|
||||
const wrap = t7 === undefined ? "wrap" : t7;
|
||||
const [themeName] = useTheme();
|
||||
const theme = getTheme(themeName);
|
||||
const hoverColor = useContext(TextHoverColorContext);
|
||||
const resolvedColor = !color && hoverColor ? resolveColor(hoverColor, theme) : dimColor ? theme.inactive as Color : resolveColor(color, theme);
|
||||
const resolvedBackgroundColor = backgroundColor ? theme[backgroundColor] as Color : undefined;
|
||||
let t8;
|
||||
if ($[0] !== bold || $[1] !== children || $[2] !== inverse || $[3] !== italic || $[4] !== resolvedBackgroundColor || $[5] !== resolvedColor || $[6] !== strikethrough || $[7] !== underline || $[8] !== wrap) {
|
||||
t8 = <Text color={resolvedColor} backgroundColor={resolvedBackgroundColor} bold={bold} italic={italic} underline={underline} strikethrough={strikethrough} inverse={inverse} wrap={wrap}>{children}</Text>;
|
||||
$[0] = bold;
|
||||
$[1] = children;
|
||||
$[2] = inverse;
|
||||
$[3] = italic;
|
||||
$[4] = resolvedBackgroundColor;
|
||||
$[5] = resolvedColor;
|
||||
$[6] = strikethrough;
|
||||
$[7] = underline;
|
||||
$[8] = wrap;
|
||||
$[9] = t8;
|
||||
} else {
|
||||
t8 = $[9];
|
||||
}
|
||||
return t8;
|
||||
export default function ThemedText({
|
||||
color,
|
||||
backgroundColor,
|
||||
dimColor = false,
|
||||
bold = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strikethrough = false,
|
||||
inverse = false,
|
||||
wrap = 'wrap',
|
||||
children,
|
||||
}: Props): React.ReactNode {
|
||||
const [themeName] = useTheme()
|
||||
const theme = getTheme(themeName)
|
||||
const hoverColor = useContext(TextHoverColorContext)
|
||||
|
||||
// Resolve theme keys to raw colors
|
||||
const resolvedColor =
|
||||
!color && hoverColor
|
||||
? resolveColor(hoverColor, theme)
|
||||
: dimColor
|
||||
? (theme.inactive as Color)
|
||||
: resolveColor(color, theme)
|
||||
const resolvedBackgroundColor = backgroundColor
|
||||
? (theme[backgroundColor] as Color)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={resolvedColor}
|
||||
backgroundColor={resolvedBackgroundColor}
|
||||
bold={bold}
|
||||
italic={italic}
|
||||
underline={underline}
|
||||
strikethrough={strikethrough}
|
||||
inverse={inverse}
|
||||
wrap={wrap}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user