@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,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,126 +35,85 @@ 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
|
-
}
|
|
52
|
-
|
|
53
|
-
if (key.name === "up") {
|
|
54
|
-
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
55
|
-
onSelectionChange(newIndex);
|
|
56
|
-
event.stopPropagation();
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
49
|
+
if (key.name === "up") {
|
|
50
|
+
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
51
|
+
onSelectionChange(newIndex);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
event.stopPropagation();
|
|
67
|
-
return;
|
|
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);
|
|
68
60
|
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
|
148
107
|
/**
|
|
149
|
-
* Get mode indicator for a command (e.g., "[cli]", "[tui]", "
|
|
108
|
+
* Get mode indicator for a command (e.g., "[cli]", "[tui]", "→" for subcommands).
|
|
150
109
|
*/
|
|
151
110
|
function getModeIndicator(command: Command): string {
|
|
152
|
-
|
|
153
|
-
|
|
111
|
+
// Show navigation indicator for container commands with navigable subcommands
|
|
112
|
+
// (excluding commands that don't support TUI)
|
|
113
|
+
const navigableSubCommands = command.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
|
|
114
|
+
if (navigableSubCommands.length > 0) {
|
|
115
|
+
return "→";
|
|
116
|
+
}
|
|
154
117
|
|
|
155
|
-
if (cli && tui) return "";
|
|
156
|
-
if (cli) return "[cli]";
|
|
157
|
-
if (tui) return "[tui]";
|
|
158
118
|
return "";
|
|
159
119
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useRef, useEffect, type ReactNode } from "react";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { Field } from "../semantic/Field.tsx";
|
|
3
|
+
import { MenuButton } from "../semantic/MenuButton.tsx";
|
|
4
|
+
import { Panel } from "../semantic/Panel.tsx";
|
|
5
|
+
import { ScrollView, type ScrollViewRef } from "../semantic/ScrollView.tsx";
|
|
6
|
+
import { Container } from "../semantic/Container.tsx";
|
|
7
|
+
import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
|
|
8
|
+
import type { KeyboardEvent } from "../adapters/types.ts";
|
|
6
9
|
import type { FieldConfig } from "./types.ts";
|
|
7
10
|
|
|
8
11
|
interface ConfigFormProps {
|
|
@@ -26,6 +29,10 @@ interface ConfigFormProps {
|
|
|
26
29
|
getDisplayValue?: (key: string, value: unknown, type: string) => string;
|
|
27
30
|
/** The action button component */
|
|
28
31
|
actionButton: ReactNode;
|
|
32
|
+
/** Optional additional buttons rendered before the main action button */
|
|
33
|
+
additionalButtons?: { label: string; onPress: () => void }[];
|
|
34
|
+
/** Optional handler for additional keys (called before default handling) */
|
|
35
|
+
onKeyDown?: (event: KeyboardEvent) => boolean;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -56,72 +63,80 @@ export function ConfigForm({
|
|
|
56
63
|
onAction,
|
|
57
64
|
getDisplayValue = defaultGetDisplayValue,
|
|
58
65
|
actionButton,
|
|
66
|
+
additionalButtons = [],
|
|
67
|
+
onKeyDown,
|
|
59
68
|
}: ConfigFormProps) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const totalFields = fieldConfigs.length + 1; // +1 for action button
|
|
69
|
+
const scrollViewRef = useRef<ScrollViewRef | null>(null);
|
|
70
|
+
const totalItems = fieldConfigs.length + additionalButtons.length + 1; // fields + additional buttons + action button
|
|
63
71
|
|
|
64
72
|
// Auto-scroll to keep selected item visible
|
|
65
73
|
useEffect(() => {
|
|
66
|
-
|
|
67
|
-
scrollboxRef.current.scrollTo(selectedIndex);
|
|
68
|
-
}
|
|
74
|
+
scrollViewRef.current?.scrollToIndex(selectedIndex);
|
|
69
75
|
}, [selectedIndex]);
|
|
70
76
|
|
|
71
|
-
// Handle keyboard events
|
|
72
|
-
|
|
73
|
-
(event) => {
|
|
74
|
-
|
|
77
|
+
// Handle keyboard events (only when focused)
|
|
78
|
+
useActiveKeyHandler(
|
|
79
|
+
(event: KeyboardEvent) => {
|
|
80
|
+
// Let parent handle first if provided
|
|
81
|
+
if (onKeyDown?.(event)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const key = event;
|
|
75
86
|
|
|
76
87
|
// Arrow key navigation
|
|
77
88
|
if (key.name === "down") {
|
|
78
|
-
const newIndex = Math.min(selectedIndex + 1,
|
|
89
|
+
const newIndex = Math.min(selectedIndex + 1, totalItems - 1);
|
|
79
90
|
onSelectionChange(newIndex);
|
|
80
|
-
|
|
81
|
-
return;
|
|
91
|
+
return true;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
if (key.name === "up") {
|
|
85
95
|
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
86
96
|
onSelectionChange(newIndex);
|
|
87
|
-
|
|
88
|
-
return;
|
|
97
|
+
return true;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
// Enter to edit field or run action
|
|
100
|
+
// Enter to edit field, press additional button, or run action
|
|
92
101
|
if (key.name === "return" || key.name === "enter") {
|
|
93
|
-
if (selectedIndex
|
|
94
|
-
|
|
95
|
-
} else {
|
|
102
|
+
if (selectedIndex < fieldConfigs.length) {
|
|
103
|
+
// It's a field
|
|
96
104
|
const fieldConfig = fieldConfigs[selectedIndex];
|
|
97
105
|
if (fieldConfig) {
|
|
98
106
|
onEditField(fieldConfig.key);
|
|
99
107
|
}
|
|
108
|
+
} else if (selectedIndex < fieldConfigs.length + additionalButtons.length) {
|
|
109
|
+
// It's an additional button
|
|
110
|
+
const buttonIndex = selectedIndex - fieldConfigs.length;
|
|
111
|
+
additionalButtons[buttonIndex]?.onPress();
|
|
112
|
+
} else {
|
|
113
|
+
// It's the main action button
|
|
114
|
+
onAction();
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
return;
|
|
116
|
+
return true;
|
|
103
117
|
}
|
|
118
|
+
|
|
119
|
+
return false;
|
|
104
120
|
},
|
|
105
|
-
KeyboardPriority.Focused,
|
|
106
121
|
{ enabled: focused }
|
|
107
122
|
);
|
|
108
123
|
|
|
109
124
|
return (
|
|
110
|
-
<
|
|
111
|
-
flexDirection="column"
|
|
112
|
-
border={true}
|
|
113
|
-
borderStyle="rounded"
|
|
114
|
-
borderColor={borderColor}
|
|
125
|
+
<Panel
|
|
115
126
|
title={title}
|
|
116
|
-
|
|
127
|
+
focused={focused}
|
|
128
|
+
flex={1}
|
|
117
129
|
padding={1}
|
|
130
|
+
flexDirection="column"
|
|
118
131
|
>
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
<ScrollView
|
|
133
|
+
axis="vertical"
|
|
134
|
+
flex={1}
|
|
135
|
+
scrollRef={(ref) => {
|
|
136
|
+
scrollViewRef.current = ref;
|
|
137
|
+
}}
|
|
123
138
|
>
|
|
124
|
-
<
|
|
139
|
+
<Container flexDirection="column" gap={0}>
|
|
125
140
|
{fieldConfigs.map((field, idx) => {
|
|
126
141
|
const isSelected = idx === selectedIndex;
|
|
127
142
|
const displayValue = getDisplayValue(
|
|
@@ -131,18 +146,29 @@ export function ConfigForm({
|
|
|
131
146
|
);
|
|
132
147
|
|
|
133
148
|
return (
|
|
134
|
-
<
|
|
149
|
+
<Field
|
|
135
150
|
key={field.key}
|
|
136
151
|
label={field.label}
|
|
137
152
|
value={displayValue}
|
|
138
|
-
|
|
153
|
+
selected={isSelected}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
|
|
158
|
+
{additionalButtons.map((btn, idx) => {
|
|
159
|
+
const buttonSelectedIndex = fieldConfigs.length + idx;
|
|
160
|
+
return (
|
|
161
|
+
<MenuButton
|
|
162
|
+
key={btn.label}
|
|
163
|
+
label={btn.label}
|
|
164
|
+
selected={selectedIndex === buttonSelectedIndex}
|
|
139
165
|
/>
|
|
140
166
|
);
|
|
141
167
|
})}
|
|
142
168
|
|
|
143
169
|
{actionButton}
|
|
144
|
-
</
|
|
145
|
-
</
|
|
146
|
-
</
|
|
170
|
+
</Container>
|
|
171
|
+
</ScrollView>
|
|
172
|
+
</Panel>
|
|
147
173
|
);
|
|
148
174
|
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { Theme } from "../theme.ts";
|
|
2
|
-
|
|
3
|
-
interface FieldRowProps {
|
|
4
|
-
/** Field label */
|
|
5
|
-
label: string;
|
|
6
|
-
/** Field value to display */
|
|
7
|
-
value: string;
|
|
8
|
-
/** Whether this row is selected */
|
|
9
|
-
isSelected: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* A single row in a config form displaying a field label and value.
|
|
14
|
-
*/
|
|
15
|
-
export function FieldRow({ label, value, isSelected }: FieldRowProps) {
|
|
16
|
-
const prefix = isSelected ? "► " : " ";
|
|
17
|
-
const labelColor = isSelected ? Theme.borderFocused : Theme.label;
|
|
18
|
-
const valueColor = isSelected ? Theme.value : Theme.statusText;
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<box flexDirection="row" gap={1}>
|
|
22
|
-
<text fg={labelColor}>
|
|
23
|
-
{prefix}{label}:
|
|
24
|
-
</text>
|
|
25
|
-
<text fg={valueColor}>
|
|
26
|
-
{value}
|
|
27
|
-
</text>
|
|
28
|
-
</box>
|
|
29
|
-
);
|
|
30
|
-
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Container } from "../semantic/Container.tsx";
|
|
2
|
+
import { Label } from "../semantic/Label.tsx";
|
|
3
|
+
import { Spacer } from "../semantic/Spacer.tsx";
|
|
2
4
|
|
|
3
5
|
interface HeaderProps {
|
|
4
6
|
/** Application name */
|
|
@@ -13,19 +15,18 @@ interface HeaderProps {
|
|
|
13
15
|
* Application header with name, version, and optional breadcrumb.
|
|
14
16
|
*/
|
|
15
17
|
export function Header({ name, version, breadcrumb }: HeaderProps) {
|
|
16
|
-
const breadcrumbStr = breadcrumb?.length
|
|
17
|
-
? ` › ${breadcrumb.join(" › ")}`
|
|
18
|
-
: "";
|
|
18
|
+
const breadcrumbStr = breadcrumb?.length ? ` › ${breadcrumb.join(" › ")}` : "";
|
|
19
19
|
|
|
20
20
|
return (
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
v{version}
|
|
28
|
-
</
|
|
29
|
-
|
|
21
|
+
<Container flexDirection="column" noShrink>
|
|
22
|
+
<Container flexDirection="row" justifyContent="space-between">
|
|
23
|
+
<Label color="mutedText" bold>
|
|
24
|
+
{name}
|
|
25
|
+
{breadcrumbStr}
|
|
26
|
+
</Label>
|
|
27
|
+
<Label color="mutedText">v{version}</Label>
|
|
28
|
+
</Container>
|
|
29
|
+
<Spacer size={1} />
|
|
30
|
+
</Container>
|
|
30
31
|
);
|
|
31
32
|
}
|
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Container } from "../semantic/Container.tsx";
|
|
2
|
+
import { CodeHighlight } from "../semantic/CodeHighlight.tsx";
|
|
3
|
+
import type { CodeTokenType } from "../semantic/types.ts";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* JSON syntax highlighting types and colors
|
|
5
7
|
*/
|
|
6
|
-
type JsonTokenType =
|
|
8
|
+
type JsonTokenType = Exclude<CodeTokenType, "unknown">;
|
|
7
9
|
type JsonToken = { type: JsonTokenType; value: string };
|
|
8
10
|
type JsonLineTokens = JsonToken[];
|
|
9
11
|
|
|
10
|
-
const TOKEN_COLORS: Record<JsonTokenType, string> = {
|
|
11
|
-
key: "#61afef", // blue
|
|
12
|
-
string: "#98c379", // green
|
|
13
|
-
number: "#d19a66", // orange
|
|
14
|
-
boolean: "#c678dd", // purple
|
|
15
|
-
null: "#c678dd", // purple
|
|
16
|
-
punctuation: Theme.label,
|
|
17
|
-
};
|
|
18
12
|
|
|
19
13
|
function tokenizeJson(value: unknown, indent = 0): JsonLineTokens[] {
|
|
20
14
|
const pad = " ".repeat(indent);
|
|
@@ -115,14 +109,13 @@ export interface JsonHighlightProps {
|
|
|
115
109
|
export function JsonHighlight({ value }: JsonHighlightProps) {
|
|
116
110
|
const lines = tokenizeJson(value);
|
|
117
111
|
return (
|
|
118
|
-
<
|
|
112
|
+
<Container flexDirection="column" gap={0}>
|
|
119
113
|
{lines.map((tokens, lineIdx) => (
|
|
120
|
-
<
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
</text>
|
|
114
|
+
<CodeHighlight
|
|
115
|
+
key={`json-${lineIdx}`}
|
|
116
|
+
tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
|
|
117
|
+
/>
|
|
125
118
|
))}
|
|
126
|
-
</
|
|
119
|
+
</Container>
|
|
127
120
|
);
|
|
128
121
|
}
|