@pablozaiden/terminatui 0.2.0 → 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 +14 -2
- package/CLAUDE.md +1 -0
- package/README.md +64 -43
- package/bun.lock +85 -0
- package/examples/tui-app/commands/config/app/get.ts +6 -10
- package/examples/tui-app/commands/config/app/index.ts +2 -6
- package/examples/tui-app/commands/config/app/set.ts +23 -13
- package/examples/tui-app/commands/config/index.ts +2 -6
- package/examples/tui-app/commands/config/user/get.ts +6 -10
- package/examples/tui-app/commands/config/user/index.ts +2 -6
- package/examples/tui-app/commands/config/user/set.ts +6 -10
- package/examples/tui-app/commands/greet.ts +13 -11
- package/examples/tui-app/commands/math.ts +5 -9
- package/examples/tui-app/commands/status.ts +21 -12
- package/examples/tui-app/index.ts +6 -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 +14 -16
- package/guides/08-complete-application.md +12 -42
- 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 +12 -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 +45 -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 -4
- 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 -619
- 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,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
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import type { KeyEvent } from "@opentui/core";
|
|
3
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
4
|
+
import type { KeyboardAdapter, KeyboardEvent, KeyHandler } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
function normalizeKeyEvent(key: KeyEvent): KeyboardEvent {
|
|
7
|
+
return {
|
|
8
|
+
name: key.name,
|
|
9
|
+
sequence: key.sequence,
|
|
10
|
+
ctrl: key.ctrl,
|
|
11
|
+
shift: key.shift,
|
|
12
|
+
meta: key.meta,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useOpenTuiKeyboardAdapter(): KeyboardAdapter {
|
|
17
|
+
const handlerStackRef = useRef<{ id: string; handler: KeyHandler }[]>([]);
|
|
18
|
+
const globalHandlerRef = useRef<KeyHandler | null>(null);
|
|
19
|
+
|
|
20
|
+
const setActiveHandler = useCallback((id: string, handler: KeyHandler) => {
|
|
21
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
22
|
+
handlerStackRef.current.push({ id, handler });
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const setGlobalHandler = useCallback((handler: KeyHandler) => {
|
|
30
|
+
const previous = globalHandlerRef.current;
|
|
31
|
+
globalHandlerRef.current = handler;
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
globalHandlerRef.current = previous;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useKeyboard((key: KeyEvent) => {
|
|
39
|
+
const event = normalizeKeyEvent(key);
|
|
40
|
+
|
|
41
|
+
if (globalHandlerRef.current) {
|
|
42
|
+
const handled = globalHandlerRef.current(event);
|
|
43
|
+
if (handled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const activeHandler = handlerStackRef.current[handlerStackRef.current.length - 1];
|
|
49
|
+
if (activeHandler) {
|
|
50
|
+
activeHandler.handler(event);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return useMemo(
|
|
55
|
+
() => ({
|
|
56
|
+
setActiveHandler,
|
|
57
|
+
setGlobalHandler,
|
|
58
|
+
}),
|
|
59
|
+
[setActiveHandler, setGlobalHandler]
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ButtonProps,
|
|
4
|
+
CodeHighlightProps,
|
|
5
|
+
CodeProps,
|
|
6
|
+
ContainerProps,
|
|
7
|
+
FieldProps,
|
|
8
|
+
LabelProps,
|
|
9
|
+
MenuButtonProps,
|
|
10
|
+
MenuItemProps,
|
|
11
|
+
OverlayProps,
|
|
12
|
+
PanelProps,
|
|
13
|
+
ScrollViewProps,
|
|
14
|
+
SelectProps,
|
|
15
|
+
SpacerProps,
|
|
16
|
+
SpinnerProps,
|
|
17
|
+
TextInputProps,
|
|
18
|
+
ValueProps,
|
|
19
|
+
} from "../semantic/types.ts";
|
|
20
|
+
|
|
21
|
+
export interface KeyboardEvent {
|
|
22
|
+
name: string;
|
|
23
|
+
sequence?: string;
|
|
24
|
+
ctrl?: boolean;
|
|
25
|
+
shift?: boolean;
|
|
26
|
+
meta?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type KeyHandler = (event: KeyboardEvent) => boolean;
|
|
30
|
+
|
|
31
|
+
export interface KeyboardAdapter {
|
|
32
|
+
setActiveHandler: (id: string, handler: KeyHandler) => () => void;
|
|
33
|
+
setGlobalHandler: (handler: KeyHandler) => () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RendererConfig {
|
|
37
|
+
useAlternateScreen?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RendererComponents {
|
|
41
|
+
Field: (props: FieldProps) => ReactNode;
|
|
42
|
+
Button: (props: ButtonProps) => ReactNode;
|
|
43
|
+
MenuButton: (props: MenuButtonProps) => ReactNode;
|
|
44
|
+
MenuItem: (props: MenuItemProps) => ReactNode;
|
|
45
|
+
|
|
46
|
+
Container: (props: ContainerProps) => ReactNode;
|
|
47
|
+
Panel: (props: PanelProps) => ReactNode;
|
|
48
|
+
ScrollView: (props: ScrollViewProps) => ReactNode;
|
|
49
|
+
|
|
50
|
+
Overlay: (props: OverlayProps) => ReactNode;
|
|
51
|
+
Spacer: (props: SpacerProps) => ReactNode;
|
|
52
|
+
Spinner: (props: SpinnerProps) => ReactNode;
|
|
53
|
+
|
|
54
|
+
Label: (props: LabelProps) => ReactNode;
|
|
55
|
+
Value: (props: ValueProps) => ReactNode;
|
|
56
|
+
Code: (props: CodeProps) => ReactNode;
|
|
57
|
+
CodeHighlight: (props: CodeHighlightProps) => ReactNode;
|
|
58
|
+
|
|
59
|
+
TextInput: (props: TextInputProps) => ReactNode;
|
|
60
|
+
Select: (props: SelectProps) => ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Renderer {
|
|
64
|
+
initialize: () => Promise<void>;
|
|
65
|
+
render: (node: ReactNode) => void;
|
|
66
|
+
destroy: () => void;
|
|
67
|
+
|
|
68
|
+
keyboard: KeyboardAdapter;
|
|
69
|
+
components: RendererComponents;
|
|
70
|
+
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { Theme } from "../theme.ts";
|
|
2
|
-
|
|
3
|
-
interface ActionButtonProps {
|
|
4
|
-
/** Button label */
|
|
5
|
-
label: string;
|
|
6
|
-
/** Whether this button is selected */
|
|
7
|
-
isSelected: boolean;
|
|
8
|
-
/** Optional spinner frame for loading state */
|
|
9
|
-
spinnerFrame?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Action button displayed at the bottom of a config form.
|
|
14
|
-
*/
|
|
15
|
-
export function ActionButton({ label, isSelected, spinnerFrame }: ActionButtonProps) {
|
|
16
|
-
const prefix = isSelected ? "► " : " ";
|
|
17
|
-
const displayLabel = spinnerFrame ? `${spinnerFrame} ${label}...` : `[ ${label} ]`;
|
|
18
|
-
|
|
19
|
-
if (isSelected) {
|
|
20
|
-
return (
|
|
21
|
-
<box marginTop={1}>
|
|
22
|
-
<text fg="#000000" bg={Theme.actionButton}>
|
|
23
|
-
{prefix}{displayLabel}
|
|
24
|
-
</text>
|
|
25
|
-
</box>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<box marginTop={1}>
|
|
31
|
-
<text fg={Theme.actionButton}>
|
|
32
|
-
{prefix}{displayLabel}
|
|
33
|
-
</text>
|
|
34
|
-
</box>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
|
|
1
|
+
import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
|
|
3
2
|
import type { Command } from "../../core/command.ts";
|
|
3
|
+
import { MenuItem } from "../semantic/MenuItem.tsx";
|
|
4
|
+
import { Container } from "../semantic/Container.tsx";
|
|
5
|
+
import { Panel } from "../semantic/Panel.tsx";
|
|
6
|
+
import { Label } from "../semantic/Label.tsx";
|
|
4
7
|
|
|
5
8
|
interface CommandItem {
|
|
6
9
|
/** The command object */
|
|
@@ -20,8 +23,6 @@ interface CommandSelectorProps {
|
|
|
20
23
|
onSelectionChange: (index: number) => void;
|
|
21
24
|
/** Called when a command is selected */
|
|
22
25
|
onSelect: (command: Command) => void;
|
|
23
|
-
/** Called when user wants to exit */
|
|
24
|
-
onExit: () => void;
|
|
25
26
|
/** Breadcrumb path for nested commands */
|
|
26
27
|
breadcrumb?: string[];
|
|
27
28
|
}
|
|
@@ -34,114 +35,72 @@ export function CommandSelector({
|
|
|
34
35
|
selectedIndex,
|
|
35
36
|
onSelectionChange,
|
|
36
37
|
onSelect,
|
|
37
|
-
onExit,
|
|
38
38
|
breadcrumb,
|
|
39
39
|
}: CommandSelectorProps) {
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
// Active keyboard handler for navigation
|
|
41
|
+
useActiveKeyHandler((key) => {
|
|
42
|
+
// Arrow key navigation
|
|
43
|
+
if (key.name === "down") {
|
|
44
|
+
const newIndex = Math.min(selectedIndex + 1, commands.length - 1);
|
|
45
|
+
onSelectionChange(newIndex);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
49
|
+
if (key.name === "up") {
|
|
50
|
+
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
51
|
+
onSelectionChange(newIndex);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// Enter to select command
|
|
56
|
+
if (key.name === "return" || key.name === "enter") {
|
|
57
|
+
const selected = commands[selectedIndex];
|
|
58
|
+
if (selected) {
|
|
59
|
+
onSelect(selected.command);
|
|
58
60
|
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const selected = commands[selectedIndex];
|
|
63
|
-
if (selected) {
|
|
64
|
-
onSelect(selected.command);
|
|
65
|
-
}
|
|
66
|
-
event.stopPropagation();
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Escape to exit or go back
|
|
71
|
-
if (key.name === "escape") {
|
|
72
|
-
onExit();
|
|
73
|
-
event.stopPropagation();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
KeyboardPriority.Focused
|
|
78
|
-
);
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
79
66
|
|
|
80
67
|
const title = breadcrumb?.length
|
|
81
68
|
? `Select Command (${breadcrumb.join(" › ")})`
|
|
82
69
|
: "Select Command";
|
|
83
70
|
|
|
84
71
|
return (
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
flexGrow={1}
|
|
88
|
-
justifyContent="center"
|
|
89
|
-
alignItems="center"
|
|
90
|
-
gap={1}
|
|
91
|
-
>
|
|
92
|
-
<box
|
|
72
|
+
<Container flexDirection="column" flex={1} justifyContent="center" alignItems="center" gap={1}>
|
|
73
|
+
<Panel
|
|
93
74
|
flexDirection="column"
|
|
94
|
-
border={true}
|
|
95
|
-
borderStyle="rounded"
|
|
96
|
-
borderColor={Theme.borderFocused}
|
|
97
75
|
title={title}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
paddingBottom={1}
|
|
102
|
-
minWidth={60}
|
|
76
|
+
padding={undefined}
|
|
77
|
+
width={60}
|
|
78
|
+
focused
|
|
103
79
|
>
|
|
104
|
-
<
|
|
80
|
+
<Container flexDirection="column" gap={1}>
|
|
105
81
|
{commands.map((item, idx) => {
|
|
106
82
|
const isSelected = idx === selectedIndex;
|
|
107
|
-
const prefix = isSelected ? "► " : " ";
|
|
108
83
|
const label = item.label ?? item.command.displayName ?? item.command.name;
|
|
109
84
|
const description = item.description ?? item.command.description;
|
|
110
85
|
|
|
111
|
-
// Show mode indicators
|
|
112
86
|
const modeIndicator = getModeIndicator(item.command);
|
|
113
87
|
|
|
114
|
-
if (isSelected) {
|
|
115
|
-
return (
|
|
116
|
-
<box key={item.command.name} flexDirection="column">
|
|
117
|
-
<text fg="#000000" bg="cyan">
|
|
118
|
-
{prefix}{label} {modeIndicator}
|
|
119
|
-
</text>
|
|
120
|
-
<text fg={Theme.label}>
|
|
121
|
-
{" "}{description}
|
|
122
|
-
</text>
|
|
123
|
-
</box>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
88
|
return (
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
89
|
+
<MenuItem
|
|
90
|
+
key={item.command.name}
|
|
91
|
+
label={label}
|
|
92
|
+
description={description}
|
|
93
|
+
suffix={modeIndicator}
|
|
94
|
+
selected={isSelected}
|
|
95
|
+
onActivate={() => onSelect(item.command)}
|
|
96
|
+
/>
|
|
136
97
|
);
|
|
137
98
|
})}
|
|
138
|
-
</
|
|
139
|
-
</
|
|
99
|
+
</Container>
|
|
100
|
+
</Panel>
|
|
140
101
|
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
</text>
|
|
144
|
-
</box>
|
|
102
|
+
<Label color="mutedText">↑/↓ Navigate • Enter Select • Esc {breadcrumb?.length ? "Back" : "Exit"}</Label>
|
|
103
|
+
</Container>
|
|
145
104
|
);
|
|
146
105
|
}
|
|
147
106
|
|
|
@@ -156,11 +115,5 @@ function getModeIndicator(command: Command): string {
|
|
|
156
115
|
return "→";
|
|
157
116
|
}
|
|
158
117
|
|
|
159
|
-
const cli = command.supportsCli();
|
|
160
|
-
const tui = command.supportsTui();
|
|
161
|
-
|
|
162
|
-
if (cli && tui) return "";
|
|
163
|
-
if (cli) return "[cli]";
|
|
164
|
-
if (tui) return "[tui]";
|
|
165
118
|
return "";
|
|
166
119
|
}
|