@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,135 @@
|
|
|
1
|
+
import type { AnyCommand } from "../core/command.ts";
|
|
2
|
+
import { useClipboard } from "./hooks/useClipboard.ts";
|
|
3
|
+
import { KeyboardProvider } from "./context/KeyboardContext.tsx";
|
|
4
|
+
import { useGlobalKeyHandler } from "./hooks/useGlobalKeyHandler.ts";
|
|
5
|
+
import { LogsProvider } from "./context/LogsContext.tsx";
|
|
6
|
+
import { NavigationProvider, useNavigation } from "./context/NavigationContext.tsx";
|
|
7
|
+
import { ClipboardProviderComponent, useClipboardContext } from "./context/ClipboardContext.tsx";
|
|
8
|
+
import { TuiAppContextProvider, useTuiApp } from "./context/TuiAppContext.tsx";
|
|
9
|
+
import { ExecutorProvider, useExecutor } from "./context/ExecutorContext.tsx";
|
|
10
|
+
import { Header } from "./components/Header.tsx";
|
|
11
|
+
import { StatusBar } from "./components/StatusBar.tsx";
|
|
12
|
+
import { Container } from "./semantic/Container.tsx";
|
|
13
|
+
import { Panel } from "./semantic/Panel.tsx";
|
|
14
|
+
import { getScreen, getModal } from "./registry.ts";
|
|
15
|
+
import { CommandSelectScreen, type CommandSelectParams } from "./screens/CommandSelectScreen.tsx";
|
|
16
|
+
|
|
17
|
+
interface TuiRootProps {
|
|
18
|
+
name: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
version: string;
|
|
21
|
+
commands: AnyCommand[];
|
|
22
|
+
onExit: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TuiRoot({ name, displayName, version, commands, onExit }: TuiRootProps) {
|
|
26
|
+
return (
|
|
27
|
+
<KeyboardProvider>
|
|
28
|
+
<ClipboardProviderComponent>
|
|
29
|
+
<TuiAppContextProvider
|
|
30
|
+
name={name}
|
|
31
|
+
displayName={displayName}
|
|
32
|
+
version={version}
|
|
33
|
+
commands={commands}
|
|
34
|
+
onExit={onExit}
|
|
35
|
+
>
|
|
36
|
+
<LogsProvider>
|
|
37
|
+
<ExecutorProvider>
|
|
38
|
+
<NavigationProvider<CommandSelectParams>
|
|
39
|
+
initialScreen={{ route: CommandSelectScreen.Id, params: { commandPath: [] } }}
|
|
40
|
+
onExit={onExit}
|
|
41
|
+
>
|
|
42
|
+
<TuiRootContent />
|
|
43
|
+
</NavigationProvider>
|
|
44
|
+
</ExecutorProvider>
|
|
45
|
+
</LogsProvider>
|
|
46
|
+
</TuiAppContextProvider>
|
|
47
|
+
</ClipboardProviderComponent>
|
|
48
|
+
</KeyboardProvider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Main TUI content - renders current screen, modals, and handles global shortcuts.
|
|
54
|
+
* This component knows NOTHING about specific screens or their logic.
|
|
55
|
+
*/
|
|
56
|
+
function TuiRootContent() {
|
|
57
|
+
const { displayName, name, version } = useTuiApp();
|
|
58
|
+
const navigation = useNavigation();
|
|
59
|
+
const executor = useExecutor();
|
|
60
|
+
const clipboard = useClipboardContext();
|
|
61
|
+
const { copyWithMessage, lastAction } = useClipboard();
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// Global keyboard handler - only truly global shortcuts
|
|
66
|
+
useGlobalKeyHandler((key) => {
|
|
67
|
+
// Esc - back/close (delegates to navigation which delegates to screen)
|
|
68
|
+
if (key.name === "escape") {
|
|
69
|
+
navigation.goBack();
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ctrl+Y - copy
|
|
74
|
+
if (key.ctrl && key.name === "y") {
|
|
75
|
+
const content = clipboard.getContent();
|
|
76
|
+
if (content) {
|
|
77
|
+
copyWithMessage(content.content, content.label);
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ctrl+L - toggle logs modal
|
|
83
|
+
if (key.ctrl && key.name === "l") {
|
|
84
|
+
const isLogsOpen = navigation.modalStack.some((m) => m.id === "logs");
|
|
85
|
+
if (isLogsOpen) {
|
|
86
|
+
navigation.closeModal();
|
|
87
|
+
} else {
|
|
88
|
+
navigation.openModal("logs");
|
|
89
|
+
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Get current screen component from registry
|
|
98
|
+
const ScreenComponent = getScreen(navigation.current.route);
|
|
99
|
+
|
|
100
|
+
// Get breadcrumb from current screen params (if available)
|
|
101
|
+
const params = navigation.current.params as { commandPath?: string[] } | undefined;
|
|
102
|
+
const breadcrumb = params?.commandPath;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Panel flexDirection="column" flex={1} padding={1} border={false}>
|
|
106
|
+
<Container flexDirection="column" flex={1}>
|
|
107
|
+
<Header name={displayName ?? name} version={version} breadcrumb={breadcrumb} />
|
|
108
|
+
|
|
109
|
+
<Container flexDirection="column" flex={1}>
|
|
110
|
+
{ScreenComponent ? <ScreenComponent /> : null}
|
|
111
|
+
</Container>
|
|
112
|
+
|
|
113
|
+
<StatusBar
|
|
114
|
+
status={lastAction || (executor.isExecuting ? "Executing..." : "Ready")}
|
|
115
|
+
isRunning={executor.isExecuting}
|
|
116
|
+
shortcuts="Esc Back • Ctrl+Y Copy • Ctrl+L Logs"
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{/* Render modals from registry */}
|
|
120
|
+
{navigation.modalStack.map((modal, idx) => {
|
|
121
|
+
const ModalComponent = getModal(modal.id);
|
|
122
|
+
if (!ModalComponent) return null;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<ModalComponent
|
|
126
|
+
key={`modal-${modal.id}-${idx}`}
|
|
127
|
+
params={modal.params}
|
|
128
|
+
onClose={() => navigation.closeModal()}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</Container>
|
|
133
|
+
</Panel>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Renderer, RendererConfig } from "./types.ts";
|
|
2
|
+
import { OpenTuiRenderer } from "./opentui/OpenTuiRenderer.tsx";
|
|
3
|
+
import { InkRenderer } from "./ink/InkRenderer.tsx";
|
|
4
|
+
import type { TuiModeOptions } from "../../core/application.ts";
|
|
5
|
+
|
|
6
|
+
export async function createRenderer(type: TuiModeOptions, config: RendererConfig): Promise<Renderer> {
|
|
7
|
+
switch (type) {
|
|
8
|
+
case "opentui": {
|
|
9
|
+
const renderer = new OpenTuiRenderer(config);
|
|
10
|
+
await renderer.initialize();
|
|
11
|
+
return renderer;
|
|
12
|
+
}
|
|
13
|
+
case "ink": {
|
|
14
|
+
const renderer = new InkRenderer(config);
|
|
15
|
+
await renderer.initialize();
|
|
16
|
+
return renderer;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useLayoutEffect } from "react";
|
|
4
|
+
|
|
5
|
+
import type { Renderer, RendererConfig } from "../types.ts";
|
|
6
|
+
import { useInkKeyboardAdapter } from "./keyboard.ts";
|
|
7
|
+
|
|
8
|
+
import { Button } from "./components/Button.tsx";
|
|
9
|
+
import { Container } from "./components/Container.tsx";
|
|
10
|
+
import { Field } from "./components/Field.tsx";
|
|
11
|
+
import { Label } from "./components/Label.tsx";
|
|
12
|
+
import { MenuButton } from "./components/MenuButton.tsx";
|
|
13
|
+
import { MenuItem } from "./components/MenuItem.tsx";
|
|
14
|
+
import { Overlay } from "./components/Overlay.tsx";
|
|
15
|
+
import { Panel } from "./components/Panel.tsx";
|
|
16
|
+
import { ScrollView } from "./components/ScrollView.tsx";
|
|
17
|
+
import { Select } from "./components/Select.tsx";
|
|
18
|
+
import { Spacer } from "./components/Spacer.tsx";
|
|
19
|
+
import { Spinner } from "./components/Spinner.tsx";
|
|
20
|
+
import { TextInput } from "./components/TextInput.tsx";
|
|
21
|
+
import { Value } from "./components/Value.tsx";
|
|
22
|
+
import { Code } from "./components/Code.tsx";
|
|
23
|
+
import { CodeHighlight } from "./components/CodeHighlight.tsx";
|
|
24
|
+
|
|
25
|
+
export class InkRenderer implements Renderer {
|
|
26
|
+
private instance: ReturnType<typeof render> | null = null;
|
|
27
|
+
private activeKeyboardAdapter: Renderer["keyboard"] | null = null;
|
|
28
|
+
|
|
29
|
+
public keyboard: Renderer["keyboard"] = {
|
|
30
|
+
setActiveHandler: (id, handler) => {
|
|
31
|
+
return this.activeKeyboardAdapter?.setActiveHandler(id, handler) ?? (() => {});
|
|
32
|
+
},
|
|
33
|
+
setGlobalHandler: (handler) => {
|
|
34
|
+
return this.activeKeyboardAdapter?.setGlobalHandler(handler) ?? (() => {});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
public components: Renderer["components"] = {
|
|
39
|
+
Field,
|
|
40
|
+
Button,
|
|
41
|
+
MenuButton,
|
|
42
|
+
MenuItem,
|
|
43
|
+
|
|
44
|
+
Container,
|
|
45
|
+
Panel,
|
|
46
|
+
ScrollView,
|
|
47
|
+
|
|
48
|
+
Overlay,
|
|
49
|
+
Spacer,
|
|
50
|
+
Spinner,
|
|
51
|
+
|
|
52
|
+
Label,
|
|
53
|
+
Value,
|
|
54
|
+
Code,
|
|
55
|
+
CodeHighlight,
|
|
56
|
+
|
|
57
|
+
Select,
|
|
58
|
+
TextInput,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
constructor(_config: RendererConfig) {}
|
|
62
|
+
|
|
63
|
+
async initialize(): Promise<void> {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render(node: ReactNode): void {
|
|
68
|
+
if (process.stdin.isTTY) {
|
|
69
|
+
try {
|
|
70
|
+
process.stdin.setRawMode(true);
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore.
|
|
73
|
+
}
|
|
74
|
+
if (process.stdin.isPaused()) {
|
|
75
|
+
process.stdin.resume();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.instance) {
|
|
80
|
+
this.instance.rerender(
|
|
81
|
+
<KeyboardBridge
|
|
82
|
+
node={node}
|
|
83
|
+
onReady={(keyboard) => {
|
|
84
|
+
this.activeKeyboardAdapter = keyboard;
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.instance = render(
|
|
92
|
+
<KeyboardBridge
|
|
93
|
+
node={node}
|
|
94
|
+
onReady={(keyboard) => {
|
|
95
|
+
this.activeKeyboardAdapter = keyboard;
|
|
96
|
+
}}
|
|
97
|
+
/>,
|
|
98
|
+
{
|
|
99
|
+
exitOnCtrlC: true,
|
|
100
|
+
patchConsole: false,
|
|
101
|
+
stdout: process.stdout,
|
|
102
|
+
stdin: process.stdin,
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
destroy(): void {
|
|
108
|
+
this.instance?.unmount();
|
|
109
|
+
this.instance = null;
|
|
110
|
+
|
|
111
|
+
if (process.stdin.isTTY) {
|
|
112
|
+
try {
|
|
113
|
+
process.stdin.setRawMode(false);
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function KeyboardBridge({
|
|
122
|
+
node,
|
|
123
|
+
onReady,
|
|
124
|
+
}: {
|
|
125
|
+
node: ReactNode;
|
|
126
|
+
onReady: (keyboard: ReturnType<typeof useInkKeyboardAdapter>) => void;
|
|
127
|
+
}) {
|
|
128
|
+
const keyboard = useInkKeyboardAdapter();
|
|
129
|
+
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
onReady(keyboard);
|
|
132
|
+
}, [onReady, keyboard]);
|
|
133
|
+
|
|
134
|
+
return node;
|
|
135
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { ButtonProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function Button({ label, selected }: ButtonProps) {
|
|
5
|
+
const prefix = selected ? "> " : " ";
|
|
6
|
+
return (
|
|
7
|
+
<Text>
|
|
8
|
+
{prefix}
|
|
9
|
+
{label}
|
|
10
|
+
</Text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { FieldProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function Field({ label, value, selected }: FieldProps) {
|
|
5
|
+
const prefix = selected ? "> " : " ";
|
|
6
|
+
return (
|
|
7
|
+
<Text>
|
|
8
|
+
{prefix}
|
|
9
|
+
{label}: {value as any}
|
|
10
|
+
</Text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { LabelProps } from "../../../semantic/types.ts";
|
|
3
|
+
import { toPlainText } from "../utils.ts";
|
|
4
|
+
|
|
5
|
+
const COLOR_MAP: Record<string, string> = {
|
|
6
|
+
text: "white",
|
|
7
|
+
mutedText: "gray",
|
|
8
|
+
primary: "cyan",
|
|
9
|
+
success: "green",
|
|
10
|
+
warning: "yellow",
|
|
11
|
+
error: "red",
|
|
12
|
+
value: "magenta",
|
|
13
|
+
code: "gray",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function Label({ color, bold, italic, children }: LabelProps) {
|
|
17
|
+
const text = toPlainText(children);
|
|
18
|
+
const inkColor = color ? (COLOR_MAP[color] ?? color) : undefined;
|
|
19
|
+
return (
|
|
20
|
+
<Text color={inkColor} bold={bold} italic={italic}>
|
|
21
|
+
{text}
|
|
22
|
+
</Text>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { MenuButtonProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function MenuButton({ label, selected }: MenuButtonProps) {
|
|
5
|
+
const prefix = selected ? "> " : " ";
|
|
6
|
+
return (
|
|
7
|
+
<Text>
|
|
8
|
+
{prefix}
|
|
9
|
+
{label}
|
|
10
|
+
</Text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { MenuItemProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function MenuItem({ label, description, suffix, selected }: MenuItemProps) {
|
|
5
|
+
const prefix = selected ? "> " : " ";
|
|
6
|
+
const desc = description ? ` — ${description}` : "";
|
|
7
|
+
const suffixText = suffix ? ` ${suffix}` : "";
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Text>
|
|
11
|
+
{prefix}
|
|
12
|
+
{label}
|
|
13
|
+
{desc}
|
|
14
|
+
{suffixText}
|
|
15
|
+
</Text>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { PanelProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function Panel({ title, children }: PanelProps) {
|
|
5
|
+
return (
|
|
6
|
+
<>
|
|
7
|
+
{title ? (
|
|
8
|
+
<Text bold>
|
|
9
|
+
{title}
|
|
10
|
+
</Text>
|
|
11
|
+
) : null}
|
|
12
|
+
{children}
|
|
13
|
+
</>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import InkSelectInput from "ink-select-input";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { SelectProps } from "../../../semantic/types.ts";
|
|
5
|
+
|
|
6
|
+
type Item = { label: string; value: string };
|
|
7
|
+
|
|
8
|
+
type ItemComponentProps = {
|
|
9
|
+
isSelected?: boolean;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function ItemComponent({ label }: ItemComponentProps) {
|
|
14
|
+
// ink-select-input already provides its own selection marker.
|
|
15
|
+
// Keep this as plain text to avoid double-marking.
|
|
16
|
+
return <Text>{label}</Text>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Select({ options, value, focused, onChange, onSubmit }: SelectProps) {
|
|
20
|
+
const items = useMemo(
|
|
21
|
+
() => options.map((o) => ({ label: o.label, value: o.value }) as Item),
|
|
22
|
+
[options]
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const initialIndex = Math.max(
|
|
26
|
+
0,
|
|
27
|
+
options.findIndex((o) => o.value === value)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// Force remount so ink-select-input respects updated initialIndex.
|
|
31
|
+
const key = `${value}:${options.length}`;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<InkSelectInput
|
|
35
|
+
key={key}
|
|
36
|
+
items={items}
|
|
37
|
+
isFocused={focused}
|
|
38
|
+
initialIndex={initialIndex}
|
|
39
|
+
itemComponent={ItemComponent}
|
|
40
|
+
onHighlight={(item) => onChange(item.value)}
|
|
41
|
+
onSelect={() => onSubmit?.()}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { SpacerProps } from "../../../semantic/types.ts";
|
|
3
|
+
|
|
4
|
+
export function Spacer({ size, axis }: SpacerProps) {
|
|
5
|
+
if (axis === "horizontal") {
|
|
6
|
+
return <Text>{" ".repeat(size)}</Text>;
|
|
7
|
+
}
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
{Array.from({ length: size }).map((_, idx) => (
|
|
11
|
+
<Text key={idx} />
|
|
12
|
+
))}
|
|
13
|
+
</>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import InkTextInput from "ink-text-input";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import type { TextInputProps } from "../../../semantic/types.ts";
|
|
4
|
+
|
|
5
|
+
export function TextInput({ value, placeholder, focused, onChange, onSubmit }: TextInputProps) {
|
|
6
|
+
// ink-text-input renders nothing if you pass empty placeholder; provide a minimal hint.
|
|
7
|
+
const hint = placeholder ?? "";
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Text>
|
|
11
|
+
<InkTextInput
|
|
12
|
+
value={value}
|
|
13
|
+
placeholder={hint}
|
|
14
|
+
focus={focused}
|
|
15
|
+
onChange={onChange}
|
|
16
|
+
onSubmit={() => {
|
|
17
|
+
onSubmit?.();
|
|
18
|
+
}}
|
|
19
|
+
/>
|
|
20
|
+
</Text>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useInput, type Key } from "ink";
|
|
2
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
3
|
+
import type { KeyboardAdapter, KeyboardEvent, KeyHandler } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
function normalizeKeyName(input: string, key: Key): KeyboardEvent {
|
|
6
|
+
const event: KeyboardEvent = {
|
|
7
|
+
name: input,
|
|
8
|
+
sequence: input,
|
|
9
|
+
ctrl: Boolean(key.ctrl),
|
|
10
|
+
shift: Boolean(key.shift),
|
|
11
|
+
meta: Boolean(key.meta),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (key.return) {
|
|
15
|
+
event.name = "return";
|
|
16
|
+
} else if (key.escape) {
|
|
17
|
+
event.name = "escape";
|
|
18
|
+
} else if (key.backspace) {
|
|
19
|
+
event.name = "backspace";
|
|
20
|
+
} else if (key.delete) {
|
|
21
|
+
// Terminals often send escape sequences for “Delete” that some libraries
|
|
22
|
+
// expose as `delete`, others as `del`. Keep it normalized.
|
|
23
|
+
event.name = "delete";
|
|
24
|
+
} else if (key.tab) {
|
|
25
|
+
event.name = "tab";
|
|
26
|
+
} else if (key.upArrow) {
|
|
27
|
+
event.name = "up";
|
|
28
|
+
} else if (key.downArrow) {
|
|
29
|
+
event.name = "down";
|
|
30
|
+
} else if (key.leftArrow) {
|
|
31
|
+
event.name = "left";
|
|
32
|
+
} else if (key.rightArrow) {
|
|
33
|
+
event.name = "right";
|
|
34
|
+
} else if (key.pageUp) {
|
|
35
|
+
event.name = "pageup";
|
|
36
|
+
} else if (key.pageDown) {
|
|
37
|
+
event.name = "pagedown";
|
|
38
|
+
} else if (key.home) {
|
|
39
|
+
event.name = "home";
|
|
40
|
+
} else if (key.end) {
|
|
41
|
+
event.name = "end";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Normalize enter -> return (some code checks either)
|
|
45
|
+
if (event.name === "enter") {
|
|
46
|
+
event.name = "return";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return event;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useInkKeyboardAdapter(): KeyboardAdapter {
|
|
53
|
+
const handlerStackRef = useRef<{ id: string; handler: KeyHandler }[]>([]);
|
|
54
|
+
const globalHandlerRef = useRef<KeyHandler | null>(null);
|
|
55
|
+
|
|
56
|
+
const setActiveHandler = useCallback((id: string, handler: KeyHandler) => {
|
|
57
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
58
|
+
handlerStackRef.current.push({ id, handler });
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const setGlobalHandler = useCallback((handler: KeyHandler) => {
|
|
66
|
+
const previous = globalHandlerRef.current;
|
|
67
|
+
globalHandlerRef.current = handler;
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
globalHandlerRef.current = previous;
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useInput((input, key) => {
|
|
75
|
+
const event = normalizeKeyName(input, key);
|
|
76
|
+
|
|
77
|
+
if (globalHandlerRef.current) {
|
|
78
|
+
const handled = globalHandlerRef.current(event);
|
|
79
|
+
if (handled) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const activeHandler = handlerStackRef.current[handlerStackRef.current.length - 1];
|
|
85
|
+
if (activeHandler) {
|
|
86
|
+
activeHandler.handler(event);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return useMemo(
|
|
91
|
+
() => ({
|
|
92
|
+
setActiveHandler,
|
|
93
|
+
setGlobalHandler,
|
|
94
|
+
}),
|
|
95
|
+
[setActiveHandler, setGlobalHandler]
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function toPlainText(node: unknown): string {
|
|
2
|
+
if (node === null || node === undefined || typeof node === "boolean") {
|
|
3
|
+
return "";
|
|
4
|
+
}
|
|
5
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
6
|
+
return String(node);
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(node)) {
|
|
9
|
+
return node.map(toPlainText).join("");
|
|
10
|
+
}
|
|
11
|
+
if (typeof node === "object" && node && "props" in node) {
|
|
12
|
+
const anyNode = node as any;
|
|
13
|
+
return toPlainText(anyNode.props?.children);
|
|
14
|
+
}
|
|
15
|
+
return "";
|
|
16
|
+
}
|