@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.
Files changed (175) hide show
  1. package/AGENTS.md +43 -0
  2. package/CLAUDE.md +1 -0
  3. package/README.md +64 -43
  4. package/bun.lock +85 -0
  5. package/examples/tui-app/commands/config/app/get.ts +62 -0
  6. package/examples/tui-app/commands/config/app/index.ts +23 -0
  7. package/examples/tui-app/commands/config/app/set.ts +96 -0
  8. package/examples/tui-app/commands/config/index.ts +28 -0
  9. package/examples/tui-app/commands/config/user/get.ts +61 -0
  10. package/examples/tui-app/commands/config/user/index.ts +23 -0
  11. package/examples/tui-app/commands/config/user/set.ts +57 -0
  12. package/examples/tui-app/commands/greet.ts +14 -11
  13. package/examples/tui-app/commands/math.ts +6 -9
  14. package/examples/tui-app/commands/status.ts +24 -13
  15. package/examples/tui-app/index.ts +7 -3
  16. package/guides/01-hello-world.md +7 -2
  17. package/guides/02-adding-options.md +2 -2
  18. package/guides/03-multiple-commands.md +6 -8
  19. package/guides/04-subcommands.md +8 -8
  20. package/guides/05-interactive-tui.md +45 -30
  21. package/guides/06-config-validation.md +4 -12
  22. package/guides/07-async-cancellation.md +15 -69
  23. package/guides/08-complete-application.md +13 -179
  24. package/guides/README.md +7 -3
  25. package/package.json +4 -8
  26. package/src/__tests__/application.test.ts +87 -68
  27. package/src/__tests__/buildCliCommand.test.ts +99 -119
  28. package/src/__tests__/builtins.test.ts +27 -75
  29. package/src/__tests__/command.test.ts +100 -131
  30. package/src/__tests__/context.test.ts +1 -26
  31. package/src/__tests__/helpCore.test.ts +227 -0
  32. package/src/__tests__/parser.test.ts +98 -244
  33. package/src/__tests__/registry.test.ts +33 -160
  34. package/src/__tests__/schemaToFields.test.ts +75 -158
  35. package/src/builtins/help.ts +19 -4
  36. package/src/builtins/settings.ts +18 -32
  37. package/src/builtins/version.ts +4 -4
  38. package/src/cli/output/colors.ts +1 -1
  39. package/src/cli/parser.ts +26 -95
  40. package/src/core/application.ts +192 -110
  41. package/src/core/command.ts +26 -9
  42. package/src/core/context.ts +31 -20
  43. package/src/core/help.ts +24 -18
  44. package/src/core/knownCommands.ts +13 -0
  45. package/src/core/logger.ts +39 -42
  46. package/src/core/registry.ts +5 -12
  47. package/src/tui/TuiApplication.tsx +63 -120
  48. package/src/tui/TuiRoot.tsx +135 -0
  49. package/src/tui/adapters/factory.ts +19 -0
  50. package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
  51. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  52. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  53. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  54. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  55. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  56. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  57. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  58. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  59. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  60. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  61. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  62. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  63. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  64. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  65. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  66. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  67. package/src/tui/adapters/ink/keyboard.ts +97 -0
  68. package/src/tui/adapters/ink/utils.ts +16 -0
  69. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
  70. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  71. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  72. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  73. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  74. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  75. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  76. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  77. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  78. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  79. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  80. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  81. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  82. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  83. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  84. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  85. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  86. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  87. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  88. package/src/tui/adapters/types.ts +70 -0
  89. package/src/tui/components/ActionButton.tsx +0 -36
  90. package/src/tui/components/CommandSelector.tsx +52 -92
  91. package/src/tui/components/ConfigForm.tsx +68 -42
  92. package/src/tui/components/FieldRow.tsx +0 -30
  93. package/src/tui/components/Header.tsx +14 -13
  94. package/src/tui/components/JsonHighlight.tsx +10 -17
  95. package/src/tui/components/ModalBase.tsx +38 -0
  96. package/src/tui/components/ResultsPanel.tsx +27 -36
  97. package/src/tui/components/StatusBar.tsx +24 -39
  98. package/src/tui/components/logColors.ts +12 -0
  99. package/src/tui/context/ClipboardContext.tsx +87 -0
  100. package/src/tui/context/ExecutorContext.tsx +139 -0
  101. package/src/tui/context/KeyboardContext.tsx +85 -71
  102. package/src/tui/context/LogsContext.tsx +35 -0
  103. package/src/tui/context/NavigationContext.tsx +194 -0
  104. package/src/tui/context/RendererContext.tsx +20 -0
  105. package/src/tui/context/TuiAppContext.tsx +58 -0
  106. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  107. package/src/tui/hooks/useBackHandler.ts +34 -0
  108. package/src/tui/hooks/useClipboard.ts +40 -25
  109. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  110. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  111. package/src/tui/modals/CliModal.tsx +82 -0
  112. package/src/tui/modals/EditorModal.tsx +207 -0
  113. package/src/tui/modals/LogsModal.tsx +98 -0
  114. package/src/tui/registry.ts +102 -0
  115. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  116. package/src/tui/screens/ConfigScreen.tsx +160 -0
  117. package/src/tui/screens/ErrorScreen.tsx +58 -0
  118. package/src/tui/screens/ResultsScreen.tsx +60 -0
  119. package/src/tui/screens/RunningScreen.tsx +72 -0
  120. package/src/tui/screens/ScreenBase.ts +6 -0
  121. package/src/tui/semantic/Button.tsx +7 -0
  122. package/src/tui/semantic/Code.tsx +7 -0
  123. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  124. package/src/tui/semantic/Container.tsx +7 -0
  125. package/src/tui/semantic/Field.tsx +7 -0
  126. package/src/tui/semantic/Label.tsx +7 -0
  127. package/src/tui/semantic/MenuButton.tsx +7 -0
  128. package/src/tui/semantic/MenuItem.tsx +7 -0
  129. package/src/tui/semantic/Overlay.tsx +7 -0
  130. package/src/tui/semantic/Panel.tsx +7 -0
  131. package/src/tui/semantic/ScrollView.tsx +9 -0
  132. package/src/tui/semantic/Select.tsx +7 -0
  133. package/src/tui/semantic/Spacer.tsx +7 -0
  134. package/src/tui/semantic/Spinner.tsx +7 -0
  135. package/src/tui/semantic/TextInput.tsx +7 -0
  136. package/src/tui/semantic/Value.tsx +7 -0
  137. package/src/tui/semantic/types.ts +195 -0
  138. package/src/tui/theme.ts +25 -14
  139. package/src/tui/utils/buildCliCommand.ts +1 -0
  140. package/src/tui/utils/getEnumKeys.ts +3 -0
  141. package/src/tui/utils/parameterPersistence.ts +1 -0
  142. package/src/types/command.ts +0 -60
  143. package/examples/tui-app/commands/index.ts +0 -3
  144. package/src/__tests__/colors.test.ts +0 -127
  145. package/src/__tests__/commandClass.test.ts +0 -130
  146. package/src/__tests__/help.test.ts +0 -412
  147. package/src/__tests__/registryNew.test.ts +0 -160
  148. package/src/__tests__/table.test.ts +0 -146
  149. package/src/__tests__/tui.test.ts +0 -26
  150. package/src/builtins/index.ts +0 -4
  151. package/src/cli/help.ts +0 -174
  152. package/src/cli/index.ts +0 -3
  153. package/src/cli/output/index.ts +0 -2
  154. package/src/cli/output/table.ts +0 -141
  155. package/src/commands/help.ts +0 -50
  156. package/src/commands/index.ts +0 -1
  157. package/src/components/index.ts +0 -147
  158. package/src/core/index.ts +0 -15
  159. package/src/hooks/index.ts +0 -131
  160. package/src/index.ts +0 -137
  161. package/src/registry/commandRegistry.ts +0 -77
  162. package/src/registry/index.ts +0 -1
  163. package/src/tui/TuiApp.tsx +0 -582
  164. package/src/tui/app.ts +0 -29
  165. package/src/tui/components/CliModal.tsx +0 -81
  166. package/src/tui/components/EditorModal.tsx +0 -177
  167. package/src/tui/components/LogsPanel.tsx +0 -86
  168. package/src/tui/components/index.ts +0 -13
  169. package/src/tui/context/index.ts +0 -7
  170. package/src/tui/hooks/index.ts +0 -35
  171. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  172. package/src/tui/hooks/useLogStream.ts +0 -96
  173. package/src/tui/index.ts +0 -65
  174. package/src/tui/utils/index.ts +0 -13
  175. 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,6 @@
1
+ import { Text } from "ink";
2
+ import type { CodeProps } from "../../../semantic/types.ts";
3
+
4
+ export function Code({ children }: CodeProps) {
5
+ return <Text color="gray">{children}</Text>;
6
+ }
@@ -0,0 +1,6 @@
1
+ import { Text } from "ink";
2
+ import type { CodeHighlightProps } from "../../../semantic/types.ts";
3
+
4
+ export function CodeHighlight({ tokens }: CodeHighlightProps) {
5
+ return <Text>{tokens.map((t) => t.value).join("")}</Text>;
6
+ }
@@ -0,0 +1,5 @@
1
+ import type { ContainerProps } from "../../../semantic/types.ts";
2
+
3
+ export function Container({ children }: ContainerProps) {
4
+ return <>{children}</>;
5
+ }
@@ -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,5 @@
1
+ import type { OverlayProps } from "../../../semantic/types.ts";
2
+
3
+ export function Overlay({ children }: OverlayProps) {
4
+ return <>{children}</>;
5
+ }
@@ -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,5 @@
1
+ import type { ScrollViewProps } from "../../../semantic/types.ts";
2
+
3
+ export function ScrollView({ children }: ScrollViewProps) {
4
+ return <>{children}</>;
5
+ }
@@ -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,5 @@
1
+ import type { SpinnerProps } from "../../../semantic/types.ts";
2
+
3
+ export function Spinner(_: SpinnerProps) {
4
+ return null;
5
+ }
@@ -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,7 @@
1
+ import { Text } from "ink";
2
+ import type { ValueProps } from "../../../semantic/types.ts";
3
+ import { toPlainText } from "../utils.ts";
4
+
5
+ export function Value({ children }: ValueProps) {
6
+ return <Text color="magenta">{toPlainText(children)}</Text>;
7
+ }
@@ -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
+ }