@pablozaiden/terminatui 0.1.2 → 0.3.0-beta-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +43 -0
- package/CLAUDE.md +1 -0
- package/README.md +64 -43
- package/bun.lock +85 -0
- package/examples/tui-app/commands/config/app/get.ts +62 -0
- package/examples/tui-app/commands/config/app/index.ts +23 -0
- package/examples/tui-app/commands/config/app/set.ts +96 -0
- package/examples/tui-app/commands/config/index.ts +28 -0
- package/examples/tui-app/commands/config/user/get.ts +61 -0
- package/examples/tui-app/commands/config/user/index.ts +23 -0
- package/examples/tui-app/commands/config/user/set.ts +57 -0
- package/examples/tui-app/commands/greet.ts +14 -11
- package/examples/tui-app/commands/math.ts +6 -9
- package/examples/tui-app/commands/status.ts +24 -13
- package/examples/tui-app/index.ts +7 -3
- package/guides/01-hello-world.md +7 -2
- package/guides/02-adding-options.md +2 -2
- package/guides/03-multiple-commands.md +6 -8
- package/guides/04-subcommands.md +8 -8
- package/guides/05-interactive-tui.md +45 -30
- package/guides/06-config-validation.md +4 -12
- package/guides/07-async-cancellation.md +15 -69
- package/guides/08-complete-application.md +13 -179
- package/guides/README.md +7 -3
- package/package.json +4 -8
- package/src/__tests__/application.test.ts +87 -68
- package/src/__tests__/buildCliCommand.test.ts +99 -119
- package/src/__tests__/builtins.test.ts +27 -75
- package/src/__tests__/command.test.ts +100 -131
- package/src/__tests__/context.test.ts +1 -26
- package/src/__tests__/helpCore.test.ts +227 -0
- package/src/__tests__/parser.test.ts +98 -244
- package/src/__tests__/registry.test.ts +33 -160
- package/src/__tests__/schemaToFields.test.ts +75 -158
- package/src/builtins/help.ts +19 -4
- package/src/builtins/settings.ts +18 -32
- package/src/builtins/version.ts +4 -4
- package/src/cli/output/colors.ts +1 -1
- package/src/cli/parser.ts +26 -95
- package/src/core/application.ts +192 -110
- package/src/core/command.ts +26 -9
- package/src/core/context.ts +31 -20
- package/src/core/help.ts +24 -18
- package/src/core/knownCommands.ts +13 -0
- package/src/core/logger.ts +39 -42
- package/src/core/registry.ts +5 -12
- package/src/tui/TuiApplication.tsx +63 -120
- package/src/tui/TuiRoot.tsx +135 -0
- package/src/tui/adapters/factory.ts +19 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
- package/src/tui/adapters/ink/components/Button.tsx +12 -0
- package/src/tui/adapters/ink/components/Code.tsx +6 -0
- package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
- package/src/tui/adapters/ink/components/Container.tsx +5 -0
- package/src/tui/adapters/ink/components/Field.tsx +12 -0
- package/src/tui/adapters/ink/components/Label.tsx +24 -0
- package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
- package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
- package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
- package/src/tui/adapters/ink/components/Panel.tsx +15 -0
- package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
- package/src/tui/adapters/ink/components/Select.tsx +44 -0
- package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
- package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
- package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
- package/src/tui/adapters/ink/components/Value.tsx +7 -0
- package/src/tui/adapters/ink/keyboard.ts +97 -0
- package/src/tui/adapters/ink/utils.ts +16 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
- package/src/tui/adapters/opentui/components/Button.tsx +13 -0
- package/src/tui/adapters/opentui/components/Code.tsx +12 -0
- package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
- package/src/tui/adapters/opentui/components/Container.tsx +56 -0
- package/src/tui/adapters/opentui/components/Field.tsx +18 -0
- package/src/tui/adapters/opentui/components/Label.tsx +15 -0
- package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
- package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
- package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
- package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
- package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
- package/src/tui/adapters/opentui/components/Select.tsx +59 -0
- package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
- package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
- package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
- package/src/tui/adapters/opentui/components/Value.tsx +13 -0
- package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
- package/src/tui/adapters/opentui/keyboard.ts +61 -0
- package/src/tui/adapters/types.ts +70 -0
- package/src/tui/components/ActionButton.tsx +0 -36
- package/src/tui/components/CommandSelector.tsx +52 -92
- package/src/tui/components/ConfigForm.tsx +68 -42
- package/src/tui/components/FieldRow.tsx +0 -30
- package/src/tui/components/Header.tsx +14 -13
- package/src/tui/components/JsonHighlight.tsx +10 -17
- package/src/tui/components/ModalBase.tsx +38 -0
- package/src/tui/components/ResultsPanel.tsx +27 -36
- package/src/tui/components/StatusBar.tsx +24 -39
- package/src/tui/components/logColors.ts +12 -0
- package/src/tui/context/ClipboardContext.tsx +87 -0
- package/src/tui/context/ExecutorContext.tsx +139 -0
- package/src/tui/context/KeyboardContext.tsx +85 -71
- package/src/tui/context/LogsContext.tsx +35 -0
- package/src/tui/context/NavigationContext.tsx +194 -0
- package/src/tui/context/RendererContext.tsx +20 -0
- package/src/tui/context/TuiAppContext.tsx +58 -0
- package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
- package/src/tui/hooks/useBackHandler.ts +34 -0
- package/src/tui/hooks/useClipboard.ts +40 -25
- package/src/tui/hooks/useClipboardProvider.ts +42 -0
- package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
- package/src/tui/modals/CliModal.tsx +82 -0
- package/src/tui/modals/EditorModal.tsx +207 -0
- package/src/tui/modals/LogsModal.tsx +98 -0
- package/src/tui/registry.ts +102 -0
- package/src/tui/screens/CommandSelectScreen.tsx +162 -0
- package/src/tui/screens/ConfigScreen.tsx +160 -0
- package/src/tui/screens/ErrorScreen.tsx +58 -0
- package/src/tui/screens/ResultsScreen.tsx +60 -0
- package/src/tui/screens/RunningScreen.tsx +72 -0
- package/src/tui/screens/ScreenBase.ts +6 -0
- package/src/tui/semantic/Button.tsx +7 -0
- package/src/tui/semantic/Code.tsx +7 -0
- package/src/tui/semantic/CodeHighlight.tsx +7 -0
- package/src/tui/semantic/Container.tsx +7 -0
- package/src/tui/semantic/Field.tsx +7 -0
- package/src/tui/semantic/Label.tsx +7 -0
- package/src/tui/semantic/MenuButton.tsx +7 -0
- package/src/tui/semantic/MenuItem.tsx +7 -0
- package/src/tui/semantic/Overlay.tsx +7 -0
- package/src/tui/semantic/Panel.tsx +7 -0
- package/src/tui/semantic/ScrollView.tsx +9 -0
- package/src/tui/semantic/Select.tsx +7 -0
- package/src/tui/semantic/Spacer.tsx +7 -0
- package/src/tui/semantic/Spinner.tsx +7 -0
- package/src/tui/semantic/TextInput.tsx +7 -0
- package/src/tui/semantic/Value.tsx +7 -0
- package/src/tui/semantic/types.ts +195 -0
- package/src/tui/theme.ts +25 -14
- package/src/tui/utils/buildCliCommand.ts +1 -0
- package/src/tui/utils/getEnumKeys.ts +3 -0
- package/src/tui/utils/parameterPersistence.ts +1 -0
- package/src/types/command.ts +0 -60
- package/examples/tui-app/commands/index.ts +0 -3
- package/src/__tests__/colors.test.ts +0 -127
- package/src/__tests__/commandClass.test.ts +0 -130
- package/src/__tests__/help.test.ts +0 -412
- package/src/__tests__/registryNew.test.ts +0 -160
- package/src/__tests__/table.test.ts +0 -146
- package/src/__tests__/tui.test.ts +0 -26
- package/src/builtins/index.ts +0 -4
- package/src/cli/help.ts +0 -174
- package/src/cli/index.ts +0 -3
- package/src/cli/output/index.ts +0 -2
- package/src/cli/output/table.ts +0 -141
- package/src/commands/help.ts +0 -50
- package/src/commands/index.ts +0 -1
- package/src/components/index.ts +0 -147
- package/src/core/index.ts +0 -15
- package/src/hooks/index.ts +0 -131
- package/src/index.ts +0 -137
- package/src/registry/commandRegistry.ts +0 -77
- package/src/registry/index.ts +0 -1
- package/src/tui/TuiApp.tsx +0 -582
- package/src/tui/app.ts +0 -29
- package/src/tui/components/CliModal.tsx +0 -81
- package/src/tui/components/EditorModal.tsx +0 -177
- package/src/tui/components/LogsPanel.tsx +0 -86
- package/src/tui/components/index.ts +0 -13
- package/src/tui/context/index.ts +0 -7
- package/src/tui/hooks/index.ts +0 -35
- package/src/tui/hooks/useKeyboardHandler.ts +0 -91
- package/src/tui/hooks/useLogStream.ts +0 -96
- package/src/tui/index.ts +0 -65
- package/src/tui/utils/index.ts +0 -13
- package/src/types/index.ts +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createCliRenderer, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import { createRoot, type Root } from "@opentui/react";
|
|
3
|
+
import { useLayoutEffect, type ReactNode } from "react";
|
|
4
|
+
import { SemanticColors } from "../../theme.ts";
|
|
5
|
+
import type { Renderer, RendererConfig } from "../types.ts";
|
|
6
|
+
import { useOpenTuiKeyboardAdapter } from "./keyboard.ts";
|
|
7
|
+
import { Button } from "./components/Button.tsx";
|
|
8
|
+
import { Code } from "./components/Code.tsx";
|
|
9
|
+
import { CodeHighlight } from "./components/CodeHighlight.tsx";
|
|
10
|
+
import { Container } from "./components/Container.tsx";
|
|
11
|
+
import { Field } from "./components/Field.tsx";
|
|
12
|
+
import { Label } from "./components/Label.tsx";
|
|
13
|
+
import { MenuButton } from "./components/MenuButton.tsx";
|
|
14
|
+
import { MenuItem } from "./components/MenuItem.tsx";
|
|
15
|
+
import { Overlay } from "./components/Overlay.tsx";
|
|
16
|
+
import { Spacer } from "./components/Spacer.tsx";
|
|
17
|
+
import { Spinner } from "./components/Spinner.tsx";
|
|
18
|
+
import { Panel } from "./components/Panel.tsx";
|
|
19
|
+
import { ScrollView as OpenTuiScrollView } from "./components/ScrollView.tsx";
|
|
20
|
+
import { Select } from "./components/Select.tsx";
|
|
21
|
+
import { TextInput } from "./components/TextInput.tsx";
|
|
22
|
+
import { Value } from "./components/Value.tsx";
|
|
23
|
+
|
|
24
|
+
export class OpenTuiRenderer implements Renderer {
|
|
25
|
+
private renderer: CliRenderer | null = null;
|
|
26
|
+
private root: Root | null = null;
|
|
27
|
+
|
|
28
|
+
private activeKeyboardAdapter: Renderer["keyboard"] | null = null;
|
|
29
|
+
|
|
30
|
+
public keyboard: Renderer["keyboard"] = {
|
|
31
|
+
setActiveHandler: (id, handler) => {
|
|
32
|
+
return this.activeKeyboardAdapter?.setActiveHandler(id, handler) ?? (() => {});
|
|
33
|
+
},
|
|
34
|
+
setGlobalHandler: (handler) => {
|
|
35
|
+
return this.activeKeyboardAdapter?.setGlobalHandler(handler) ?? (() => {});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
public components: Renderer["components"] = {
|
|
40
|
+
Field,
|
|
41
|
+
Button,
|
|
42
|
+
MenuButton,
|
|
43
|
+
MenuItem,
|
|
44
|
+
Container,
|
|
45
|
+
Panel,
|
|
46
|
+
ScrollView: OpenTuiScrollView,
|
|
47
|
+
|
|
48
|
+
Overlay,
|
|
49
|
+
Spacer,
|
|
50
|
+
Spinner,
|
|
51
|
+
Label,
|
|
52
|
+
Value,
|
|
53
|
+
Code,
|
|
54
|
+
CodeHighlight,
|
|
55
|
+
|
|
56
|
+
Select,
|
|
57
|
+
TextInput,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
constructor(private readonly config: RendererConfig) {}
|
|
61
|
+
|
|
62
|
+
async initialize(): Promise<void> {
|
|
63
|
+
this.renderer = await createCliRenderer({
|
|
64
|
+
useAlternateScreen: this.config.useAlternateScreen ?? true,
|
|
65
|
+
useConsole: false,
|
|
66
|
+
exitOnCtrlC: true,
|
|
67
|
+
backgroundColor: SemanticColors.background,
|
|
68
|
+
useMouse: true,
|
|
69
|
+
enableMouseMovement: true,
|
|
70
|
+
openConsoleOnError: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.root = createRoot(this.renderer);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(node: ReactNode): void {
|
|
77
|
+
if (!this.root) {
|
|
78
|
+
throw new Error("OpenTuiRenderer not initialized");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.root.render(
|
|
82
|
+
<KeyboardBridge
|
|
83
|
+
onReady={(keyboard) => {
|
|
84
|
+
this.activeKeyboardAdapter = keyboard;
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{node}
|
|
88
|
+
</KeyboardBridge>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
this.renderer?.start();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
destroy(): void {
|
|
95
|
+
this.renderer?.destroy();
|
|
96
|
+
this.renderer = null;
|
|
97
|
+
this.root = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function KeyboardBridge({
|
|
102
|
+
children,
|
|
103
|
+
onReady,
|
|
104
|
+
}: {
|
|
105
|
+
children: ReactNode;
|
|
106
|
+
onReady: (keyboard: ReturnType<typeof useOpenTuiKeyboardAdapter>) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const keyboard = useOpenTuiKeyboardAdapter();
|
|
109
|
+
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
onReady(keyboard);
|
|
112
|
+
}, [onReady, keyboard]);
|
|
113
|
+
|
|
114
|
+
return <>{children}</>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ButtonProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
3
|
+
|
|
4
|
+
export function Button({ label, selected, onActivate }: ButtonProps) {
|
|
5
|
+
const fg = selected ? SemanticColors.selectionText : SemanticColors.text;
|
|
6
|
+
const bg = selected ? SemanticColors.selectionBackground : undefined;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<text fg={fg} bg={bg} {...({ onClick: onActivate })}>
|
|
10
|
+
{label}
|
|
11
|
+
</text>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CodeProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
3
|
+
|
|
4
|
+
export function Code({ color = "code", children }: CodeProps) {
|
|
5
|
+
const fg = SemanticColors[color] ?? SemanticColors.code;
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<text fg={fg}>
|
|
9
|
+
{children}
|
|
10
|
+
</text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CodeHighlightProps, CodeTokenType } from "../../../semantic/types.ts";
|
|
2
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
3
|
+
|
|
4
|
+
const TOKEN_COLORS: Record<CodeTokenType, string> = {
|
|
5
|
+
key: SemanticColors.primary,
|
|
6
|
+
string: SemanticColors.success,
|
|
7
|
+
number: "#d19a66",
|
|
8
|
+
boolean: "#c678dd",
|
|
9
|
+
null: "#c678dd",
|
|
10
|
+
punctuation: SemanticColors.mutedText,
|
|
11
|
+
unknown: SemanticColors.text,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function CodeHighlight({ tokens }: CodeHighlightProps) {
|
|
15
|
+
return (
|
|
16
|
+
<text>
|
|
17
|
+
{tokens.map((token, tokenIdx) => (
|
|
18
|
+
<span key={tokenIdx} fg={TOKEN_COLORS[token.type] ?? SemanticColors.text}>
|
|
19
|
+
{token.value}
|
|
20
|
+
</span>
|
|
21
|
+
))}
|
|
22
|
+
</text>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ContainerProps, Spacing } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
function normalizePadding(padding: number | Spacing | undefined):
|
|
5
|
+
| { padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number }
|
|
6
|
+
| undefined {
|
|
7
|
+
if (padding === undefined) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof padding === "number") {
|
|
12
|
+
return { padding };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
paddingTop: padding.top ?? 0,
|
|
17
|
+
paddingRight: padding.right ?? 0,
|
|
18
|
+
paddingBottom: padding.bottom ?? 0,
|
|
19
|
+
paddingLeft: padding.left ?? 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Container({
|
|
24
|
+
children,
|
|
25
|
+
flex,
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
flexDirection,
|
|
29
|
+
alignItems,
|
|
30
|
+
justifyContent,
|
|
31
|
+
gap,
|
|
32
|
+
padding,
|
|
33
|
+
noShrink,
|
|
34
|
+
}: ContainerProps & { children?: ReactNode }) {
|
|
35
|
+
const resolvedPadding = normalizePadding(padding);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<box
|
|
39
|
+
flexGrow={flex}
|
|
40
|
+
flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
|
|
41
|
+
width={width as any}
|
|
42
|
+
height={height as any}
|
|
43
|
+
flexDirection={flexDirection as any}
|
|
44
|
+
alignItems={alignItems as any}
|
|
45
|
+
justifyContent={justifyContent as any}
|
|
46
|
+
gap={gap}
|
|
47
|
+
padding={resolvedPadding?.padding}
|
|
48
|
+
paddingTop={resolvedPadding?.paddingTop}
|
|
49
|
+
paddingRight={resolvedPadding?.paddingRight}
|
|
50
|
+
paddingBottom={resolvedPadding?.paddingBottom}
|
|
51
|
+
paddingLeft={resolvedPadding?.paddingLeft}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FieldProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
3
|
+
|
|
4
|
+
export function Field({ label, value, selected, onActivate }: FieldProps) {
|
|
5
|
+
const prefix = selected ? "► " : " ";
|
|
6
|
+
const labelColor = selected ? SemanticColors.focusBorder : SemanticColors.mutedText;
|
|
7
|
+
const valueColor = selected ? SemanticColors.value : SemanticColors.text;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<box flexDirection="row" gap={1} {...({ onClick: onActivate })}>
|
|
11
|
+
<text fg={labelColor}>
|
|
12
|
+
{prefix}
|
|
13
|
+
{label}:
|
|
14
|
+
</text>
|
|
15
|
+
<text fg={valueColor}>{value}</text>
|
|
16
|
+
</box>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LabelProps } from "../../../semantic/types.ts";
|
|
3
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
4
|
+
|
|
5
|
+
export function Label({ color = "text", bold, italic, wrap, children }: LabelProps & { children: ReactNode }) {
|
|
6
|
+
const fg = SemanticColors[color] ?? SemanticColors.text;
|
|
7
|
+
|
|
8
|
+
const content = bold ? <strong>{children}</strong> : children;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<text fg={fg} {...({ wrap } as any)}>
|
|
12
|
+
{italic ? <em>{content}</em> : content}
|
|
13
|
+
</text>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MenuButtonProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { MenuItem } from "./MenuItem.tsx";
|
|
3
|
+
|
|
4
|
+
export function MenuButton({ label, selected, onActivate }: MenuButtonProps) {
|
|
5
|
+
return (
|
|
6
|
+
<box marginTop={1}>
|
|
7
|
+
<MenuItem
|
|
8
|
+
label={`[ ${label} ]`}
|
|
9
|
+
selected={selected}
|
|
10
|
+
onActivate={onActivate}
|
|
11
|
+
/>
|
|
12
|
+
</box>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MenuItemProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
3
|
+
|
|
4
|
+
export function MenuItem({
|
|
5
|
+
label,
|
|
6
|
+
description,
|
|
7
|
+
suffix,
|
|
8
|
+
selected,
|
|
9
|
+
onActivate,
|
|
10
|
+
}: MenuItemProps) {
|
|
11
|
+
const prefix = selected ? "► " : " ";
|
|
12
|
+
const displayLabel = suffix ? `${label} ${suffix}` : label;
|
|
13
|
+
|
|
14
|
+
const fg = selected ? SemanticColors.selectionText : SemanticColors.text;
|
|
15
|
+
const bg = selected ? SemanticColors.selectionBackground : undefined;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box flexDirection="column">
|
|
19
|
+
<text fg={fg} bg={bg} {...({ onClick: onActivate })}>
|
|
20
|
+
{prefix}{displayLabel}
|
|
21
|
+
</text>
|
|
22
|
+
{description ? (
|
|
23
|
+
<text fg={selected ? SemanticColors.text : SemanticColors.mutedText}>
|
|
24
|
+
{" "}{description}
|
|
25
|
+
</text>
|
|
26
|
+
) : null}
|
|
27
|
+
</box>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { OverlayProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function Overlay({
|
|
5
|
+
zIndex = 10,
|
|
6
|
+
top,
|
|
7
|
+
left,
|
|
8
|
+
right,
|
|
9
|
+
bottom,
|
|
10
|
+
width,
|
|
11
|
+
height,
|
|
12
|
+
children,
|
|
13
|
+
}: OverlayProps & { children?: ReactNode }) {
|
|
14
|
+
return (
|
|
15
|
+
<box position="absolute" top={0} left={0} right={0} bottom={0} zIndex={zIndex}>
|
|
16
|
+
<box position="absolute" top={top as any} left={left as any} right={right as any} bottom={bottom as any} width={width as any} height={height as any}>
|
|
17
|
+
{children}
|
|
18
|
+
</box>
|
|
19
|
+
</box>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { PanelProps, Spacing } from "../../../semantic/types.ts";
|
|
3
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
4
|
+
|
|
5
|
+
function normalizePadding(
|
|
6
|
+
padding: number | Spacing | undefined,
|
|
7
|
+
opts: { dense: boolean }
|
|
8
|
+
): { padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number } {
|
|
9
|
+
if (padding === undefined) {
|
|
10
|
+
return opts.dense
|
|
11
|
+
? {
|
|
12
|
+
padding: 0,
|
|
13
|
+
paddingLeft: 1,
|
|
14
|
+
paddingRight: 1,
|
|
15
|
+
}
|
|
16
|
+
: { padding: 1 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof padding === "number") {
|
|
20
|
+
return { padding };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
paddingTop: padding.top ?? 0,
|
|
25
|
+
paddingRight: padding.right ?? 0,
|
|
26
|
+
paddingBottom: padding.bottom ?? 0,
|
|
27
|
+
paddingLeft: padding.left ?? 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Panel({
|
|
32
|
+
title,
|
|
33
|
+
focused,
|
|
34
|
+
border = true,
|
|
35
|
+
surface = "panel",
|
|
36
|
+
dense = false,
|
|
37
|
+
children,
|
|
38
|
+
flex,
|
|
39
|
+
width,
|
|
40
|
+
height,
|
|
41
|
+
flexDirection,
|
|
42
|
+
alignItems,
|
|
43
|
+
justifyContent,
|
|
44
|
+
gap,
|
|
45
|
+
padding,
|
|
46
|
+
noShrink,
|
|
47
|
+
}: PanelProps & { children?: ReactNode }) {
|
|
48
|
+
const backgroundColor = surface === "overlay" ? SemanticColors.overlay : SemanticColors.panelBackground;
|
|
49
|
+
|
|
50
|
+
const borderColor = surface === "overlay" ? SemanticColors.warning : focused ? SemanticColors.focusBorder : SemanticColors.border;
|
|
51
|
+
|
|
52
|
+
const resolvedPadding = normalizePadding(padding, { dense });
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
border={border}
|
|
57
|
+
borderStyle={border ? "rounded" : undefined}
|
|
58
|
+
borderColor={borderColor}
|
|
59
|
+
title={title}
|
|
60
|
+
padding={resolvedPadding.padding}
|
|
61
|
+
paddingTop={resolvedPadding.paddingTop}
|
|
62
|
+
paddingRight={resolvedPadding.paddingRight}
|
|
63
|
+
paddingBottom={resolvedPadding.paddingBottom}
|
|
64
|
+
paddingLeft={resolvedPadding.paddingLeft}
|
|
65
|
+
flexGrow={flex}
|
|
66
|
+
flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
|
|
67
|
+
width={width as any}
|
|
68
|
+
height={height as any}
|
|
69
|
+
flexDirection={flexDirection as any}
|
|
70
|
+
alignItems={alignItems as any}
|
|
71
|
+
justifyContent={justifyContent as any}
|
|
72
|
+
gap={gap}
|
|
73
|
+
backgroundColor={backgroundColor}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</box>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef, type ReactNode } from "react";
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import type { ScrollViewProps, ScrollViewRef, Spacing } from "../../../semantic/types.ts";
|
|
4
|
+
|
|
5
|
+
function normalizePadding(padding: number | Spacing | undefined): any {
|
|
6
|
+
if (padding === undefined) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof padding === "number") {
|
|
11
|
+
return padding;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
top: padding.top ?? 0,
|
|
16
|
+
right: padding.right ?? 0,
|
|
17
|
+
bottom: padding.bottom ?? 0,
|
|
18
|
+
left: padding.left ?? 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ScrollView({
|
|
23
|
+
axis = "vertical",
|
|
24
|
+
stickyToEnd,
|
|
25
|
+
focused,
|
|
26
|
+
scrollRef: onScrollRef,
|
|
27
|
+
children,
|
|
28
|
+
flex,
|
|
29
|
+
width,
|
|
30
|
+
height,
|
|
31
|
+
flexDirection,
|
|
32
|
+
alignItems,
|
|
33
|
+
justifyContent,
|
|
34
|
+
gap,
|
|
35
|
+
padding,
|
|
36
|
+
}: ScrollViewProps & { children?: ReactNode }) {
|
|
37
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
38
|
+
|
|
39
|
+
const imperativeApi: ScrollViewRef = {
|
|
40
|
+
scrollToTop: () => {
|
|
41
|
+
scrollRef.current?.scrollTo(0);
|
|
42
|
+
},
|
|
43
|
+
scrollToBottom: () => {
|
|
44
|
+
// No public "bottom" API in ScrollBoxRenderable; use large index.
|
|
45
|
+
scrollRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
|
46
|
+
},
|
|
47
|
+
scrollToIndex: (index: number) => {
|
|
48
|
+
scrollRef.current?.scrollTo(index);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Provide the imperative API via callback.
|
|
53
|
+
if (onScrollRef) {
|
|
54
|
+
onScrollRef(imperativeApi);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const scrollY = axis === "vertical" || axis === "both";
|
|
58
|
+
const scrollX = axis === "horizontal" || axis === "both";
|
|
59
|
+
|
|
60
|
+
const resolvedStickyToEnd = stickyToEnd ? true : undefined;
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<scrollbox
|
|
65
|
+
ref={scrollRef}
|
|
66
|
+
scrollY={scrollY}
|
|
67
|
+
scrollX={scrollX}
|
|
68
|
+
focused={focused}
|
|
69
|
+
{...({ stickyToEnd: resolvedStickyToEnd })}
|
|
70
|
+
flexGrow={flex}
|
|
71
|
+
width={width as any}
|
|
72
|
+
height={height as any}
|
|
73
|
+
>
|
|
74
|
+
<box
|
|
75
|
+
flexDirection={flexDirection as any}
|
|
76
|
+
alignItems={alignItems as any}
|
|
77
|
+
justifyContent={justifyContent as any}
|
|
78
|
+
gap={gap}
|
|
79
|
+
padding={normalizePadding(padding)}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</box>
|
|
83
|
+
</scrollbox>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SelectProps } from "../../../semantic/types.ts";
|
|
2
|
+
import type { SelectOption as OpenTuiSelectOption } from "@opentui/core";
|
|
3
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
4
|
+
|
|
5
|
+
export function Select<TValue extends string>({
|
|
6
|
+
options,
|
|
7
|
+
value,
|
|
8
|
+
focused,
|
|
9
|
+
onChange,
|
|
10
|
+
onSubmit,
|
|
11
|
+
}: SelectProps) {
|
|
12
|
+
const selectedIndex = Math.max(
|
|
13
|
+
0,
|
|
14
|
+
options.findIndex((opt) => opt.value === value)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<select
|
|
19
|
+
options={
|
|
20
|
+
options.map(
|
|
21
|
+
(opt): OpenTuiSelectOption => ({
|
|
22
|
+
name: opt.label,
|
|
23
|
+
description: "",
|
|
24
|
+
value: opt.value,
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
selectedIndex={selectedIndex}
|
|
29
|
+
focused={focused}
|
|
30
|
+
onChange={(idx: number) => {
|
|
31
|
+
const next = options[idx];
|
|
32
|
+
if (next) {
|
|
33
|
+
onChange(next.value);
|
|
34
|
+
}
|
|
35
|
+
}}
|
|
36
|
+
onSelect={(idx: number, option: OpenTuiSelectOption | null) => {
|
|
37
|
+
if (option) {
|
|
38
|
+
onChange(option.value as TValue);
|
|
39
|
+
} else {
|
|
40
|
+
const next = options[idx];
|
|
41
|
+
if (next) {
|
|
42
|
+
onChange(next.value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Only submit when OpenTUI triggers selection (Enter).
|
|
47
|
+
// Arrow navigation uses onChange only.
|
|
48
|
+
onSubmit?.();
|
|
49
|
+
}}
|
|
50
|
+
showScrollIndicator={false}
|
|
51
|
+
showDescription={false}
|
|
52
|
+
height={Math.min(options.length, 10)}
|
|
53
|
+
width="100%"
|
|
54
|
+
wrapSelection={true}
|
|
55
|
+
selectedBackgroundColor={SemanticColors.focusBorder}
|
|
56
|
+
selectedTextColor={SemanticColors.inverseText}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SpinnerProps } from "../../../semantic/types.ts";
|
|
2
|
+
import { useSpinner } from "../hooks/useSpinner.ts";
|
|
3
|
+
|
|
4
|
+
export function Spinner({ active }: SpinnerProps) {
|
|
5
|
+
const { frame } = useSpinner(active);
|
|
6
|
+
|
|
7
|
+
if (!active) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return <text>{frame} </text>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TextInputProps } from "../../../semantic/types.ts";
|
|
2
|
+
|
|
3
|
+
export function TextInput({ value, placeholder, focused, onChange, onSubmit }: TextInputProps) {
|
|
4
|
+
return (
|
|
5
|
+
<input
|
|
6
|
+
value={value}
|
|
7
|
+
placeholder={placeholder}
|
|
8
|
+
focused={focused}
|
|
9
|
+
onInput={(next: string) => onChange(next)}
|
|
10
|
+
onSubmit={() => onSubmit?.()}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ValueProps } from "../../../semantic/types.ts";
|
|
3
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
4
|
+
|
|
5
|
+
export function Value({ color = "value", truncate, children }: ValueProps & { children: ReactNode }) {
|
|
6
|
+
const fg = SemanticColors[color] ?? SemanticColors.value;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<text fg={fg} {...({ truncate })}>
|
|
10
|
+
{children}
|
|
11
|
+
</text>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
|
|
3
3
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
4
|
const SPINNER_INTERVAL = 80;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
/** Current frame index */
|
|
6
|
+
interface UseSpinnerResult {
|
|
8
7
|
frameIndex: number;
|
|
9
|
-
/** Current spinner character */
|
|
10
8
|
frame: string;
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
/**
|
|
14
|
-
* Hook for animated spinner.
|
|
15
|
-
*
|
|
16
|
-
* @param active - Whether the spinner is active
|
|
17
|
-
* @returns Spinner state with current frame
|
|
18
|
-
*/
|
|
19
11
|
export function useSpinner(active: boolean): UseSpinnerResult {
|
|
20
12
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
21
13
|
|
|
@@ -27,7 +19,6 @@ export function useSpinner(active: boolean): UseSpinnerResult {
|
|
|
27
19
|
|
|
28
20
|
const interval = setInterval(() => {
|
|
29
21
|
setFrameIndex((prev) => {
|
|
30
|
-
// Reset to avoid overflow
|
|
31
22
|
if (prev >= Number.MAX_SAFE_INTEGER / 2) {
|
|
32
23
|
return 0;
|
|
33
24
|
}
|