@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,115 @@
1
+ import { createCliRenderer, type CliRenderer } from "@opentui/core";
2
+ import { createRoot, type Root } from "@opentui/react";
3
+ import { useLayoutEffect, type ReactNode } from "react";
4
+ import { SemanticColors } from "../../theme.ts";
5
+ import type { Renderer, RendererConfig } from "../types.ts";
6
+ import { useOpenTuiKeyboardAdapter } from "./keyboard.ts";
7
+ import { Button } from "./components/Button.tsx";
8
+ import { Code } from "./components/Code.tsx";
9
+ import { CodeHighlight } from "./components/CodeHighlight.tsx";
10
+ import { Container } from "./components/Container.tsx";
11
+ import { Field } from "./components/Field.tsx";
12
+ import { Label } from "./components/Label.tsx";
13
+ import { MenuButton } from "./components/MenuButton.tsx";
14
+ import { MenuItem } from "./components/MenuItem.tsx";
15
+ import { Overlay } from "./components/Overlay.tsx";
16
+ import { Spacer } from "./components/Spacer.tsx";
17
+ import { Spinner } from "./components/Spinner.tsx";
18
+ import { Panel } from "./components/Panel.tsx";
19
+ import { ScrollView as OpenTuiScrollView } from "./components/ScrollView.tsx";
20
+ import { Select } from "./components/Select.tsx";
21
+ import { TextInput } from "./components/TextInput.tsx";
22
+ import { Value } from "./components/Value.tsx";
23
+
24
+ export class OpenTuiRenderer implements Renderer {
25
+ private renderer: CliRenderer | null = null;
26
+ private root: Root | null = null;
27
+
28
+ private activeKeyboardAdapter: Renderer["keyboard"] | null = null;
29
+
30
+ public keyboard: Renderer["keyboard"] = {
31
+ setActiveHandler: (id, handler) => {
32
+ return this.activeKeyboardAdapter?.setActiveHandler(id, handler) ?? (() => {});
33
+ },
34
+ setGlobalHandler: (handler) => {
35
+ return this.activeKeyboardAdapter?.setGlobalHandler(handler) ?? (() => {});
36
+ },
37
+ };
38
+
39
+ public components: Renderer["components"] = {
40
+ Field,
41
+ Button,
42
+ MenuButton,
43
+ MenuItem,
44
+ Container,
45
+ Panel,
46
+ ScrollView: OpenTuiScrollView,
47
+
48
+ Overlay,
49
+ Spacer,
50
+ Spinner,
51
+ Label,
52
+ Value,
53
+ Code,
54
+ CodeHighlight,
55
+
56
+ Select,
57
+ TextInput,
58
+ };
59
+
60
+ constructor(private readonly config: RendererConfig) {}
61
+
62
+ async initialize(): Promise<void> {
63
+ this.renderer = await createCliRenderer({
64
+ useAlternateScreen: this.config.useAlternateScreen ?? true,
65
+ useConsole: false,
66
+ exitOnCtrlC: true,
67
+ backgroundColor: SemanticColors.background,
68
+ useMouse: true,
69
+ enableMouseMovement: true,
70
+ openConsoleOnError: false,
71
+ });
72
+
73
+ this.root = createRoot(this.renderer);
74
+ }
75
+
76
+ render(node: ReactNode): void {
77
+ if (!this.root) {
78
+ throw new Error("OpenTuiRenderer not initialized");
79
+ }
80
+
81
+ this.root.render(
82
+ <KeyboardBridge
83
+ onReady={(keyboard) => {
84
+ this.activeKeyboardAdapter = keyboard;
85
+ }}
86
+ >
87
+ {node}
88
+ </KeyboardBridge>
89
+ );
90
+
91
+ this.renderer?.start();
92
+ }
93
+
94
+ destroy(): void {
95
+ this.renderer?.destroy();
96
+ this.renderer = null;
97
+ this.root = null;
98
+ }
99
+ }
100
+
101
+ function KeyboardBridge({
102
+ children,
103
+ onReady,
104
+ }: {
105
+ children: ReactNode;
106
+ onReady: (keyboard: ReturnType<typeof useOpenTuiKeyboardAdapter>) => void;
107
+ }) {
108
+ const keyboard = useOpenTuiKeyboardAdapter();
109
+
110
+ useLayoutEffect(() => {
111
+ onReady(keyboard);
112
+ }, [onReady, keyboard]);
113
+
114
+ return <>{children}</>;
115
+ }
@@ -0,0 +1,13 @@
1
+ import type { ButtonProps } from "../../../semantic/types.ts";
2
+ import { SemanticColors } from "../../../theme.ts";
3
+
4
+ export function Button({ label, selected, onActivate }: ButtonProps) {
5
+ const fg = selected ? SemanticColors.selectionText : SemanticColors.text;
6
+ const bg = selected ? SemanticColors.selectionBackground : undefined;
7
+
8
+ return (
9
+ <text fg={fg} bg={bg} {...({ onClick: onActivate })}>
10
+ {label}
11
+ </text>
12
+ );
13
+ }
@@ -0,0 +1,12 @@
1
+ import type { CodeProps } from "../../../semantic/types.ts";
2
+ import { SemanticColors } from "../../../theme.ts";
3
+
4
+ export function Code({ color = "code", children }: CodeProps) {
5
+ const fg = SemanticColors[color] ?? SemanticColors.code;
6
+
7
+ return (
8
+ <text fg={fg}>
9
+ {children}
10
+ </text>
11
+ );
12
+ }
@@ -0,0 +1,24 @@
1
+ import type { CodeHighlightProps, CodeTokenType } from "../../../semantic/types.ts";
2
+ import { SemanticColors } from "../../../theme.ts";
3
+
4
+ const TOKEN_COLORS: Record<CodeTokenType, string> = {
5
+ key: SemanticColors.primary,
6
+ string: SemanticColors.success,
7
+ number: "#d19a66",
8
+ boolean: "#c678dd",
9
+ null: "#c678dd",
10
+ punctuation: SemanticColors.mutedText,
11
+ unknown: SemanticColors.text,
12
+ };
13
+
14
+ export function CodeHighlight({ tokens }: CodeHighlightProps) {
15
+ return (
16
+ <text>
17
+ {tokens.map((token, tokenIdx) => (
18
+ <span key={tokenIdx} fg={TOKEN_COLORS[token.type] ?? SemanticColors.text}>
19
+ {token.value}
20
+ </span>
21
+ ))}
22
+ </text>
23
+ );
24
+ }
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ContainerProps, Spacing } from "../../../semantic/types.ts";
3
+
4
+ function normalizePadding(padding: number | Spacing | undefined):
5
+ | { padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number }
6
+ | undefined {
7
+ if (padding === undefined) {
8
+ return undefined;
9
+ }
10
+
11
+ if (typeof padding === "number") {
12
+ return { padding };
13
+ }
14
+
15
+ return {
16
+ paddingTop: padding.top ?? 0,
17
+ paddingRight: padding.right ?? 0,
18
+ paddingBottom: padding.bottom ?? 0,
19
+ paddingLeft: padding.left ?? 0,
20
+ };
21
+ }
22
+
23
+ export function Container({
24
+ children,
25
+ flex,
26
+ width,
27
+ height,
28
+ flexDirection,
29
+ alignItems,
30
+ justifyContent,
31
+ gap,
32
+ padding,
33
+ noShrink,
34
+ }: ContainerProps & { children?: ReactNode }) {
35
+ const resolvedPadding = normalizePadding(padding);
36
+
37
+ return (
38
+ <box
39
+ flexGrow={flex}
40
+ flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
41
+ width={width as any}
42
+ height={height as any}
43
+ flexDirection={flexDirection as any}
44
+ alignItems={alignItems as any}
45
+ justifyContent={justifyContent as any}
46
+ gap={gap}
47
+ padding={resolvedPadding?.padding}
48
+ paddingTop={resolvedPadding?.paddingTop}
49
+ paddingRight={resolvedPadding?.paddingRight}
50
+ paddingBottom={resolvedPadding?.paddingBottom}
51
+ paddingLeft={resolvedPadding?.paddingLeft}
52
+ >
53
+ {children}
54
+ </box>
55
+ );
56
+ }
@@ -0,0 +1,18 @@
1
+ import type { FieldProps } from "../../../semantic/types.ts";
2
+ import { SemanticColors } from "../../../theme.ts";
3
+
4
+ export function Field({ label, value, selected, onActivate }: FieldProps) {
5
+ const prefix = selected ? "► " : " ";
6
+ const labelColor = selected ? SemanticColors.focusBorder : SemanticColors.mutedText;
7
+ const valueColor = selected ? SemanticColors.value : SemanticColors.text;
8
+
9
+ return (
10
+ <box flexDirection="row" gap={1} {...({ onClick: onActivate })}>
11
+ <text fg={labelColor}>
12
+ {prefix}
13
+ {label}:
14
+ </text>
15
+ <text fg={valueColor}>{value}</text>
16
+ </box>
17
+ );
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LabelProps } from "../../../semantic/types.ts";
3
+ import { SemanticColors } from "../../../theme.ts";
4
+
5
+ export function Label({ color = "text", bold, italic, wrap, children }: LabelProps & { children: ReactNode }) {
6
+ const fg = SemanticColors[color] ?? SemanticColors.text;
7
+
8
+ const content = bold ? <strong>{children}</strong> : children;
9
+
10
+ return (
11
+ <text fg={fg} {...({ wrap } as any)}>
12
+ {italic ? <em>{content}</em> : content}
13
+ </text>
14
+ );
15
+ }
@@ -0,0 +1,14 @@
1
+ import type { MenuButtonProps } from "../../../semantic/types.ts";
2
+ import { MenuItem } from "./MenuItem.tsx";
3
+
4
+ export function MenuButton({ label, selected, onActivate }: MenuButtonProps) {
5
+ return (
6
+ <box marginTop={1}>
7
+ <MenuItem
8
+ label={`[ ${label} ]`}
9
+ selected={selected}
10
+ onActivate={onActivate}
11
+ />
12
+ </box>
13
+ );
14
+ }
@@ -0,0 +1,29 @@
1
+ import type { MenuItemProps } from "../../../semantic/types.ts";
2
+ import { SemanticColors } from "../../../theme.ts";
3
+
4
+ export function MenuItem({
5
+ label,
6
+ description,
7
+ suffix,
8
+ selected,
9
+ onActivate,
10
+ }: MenuItemProps) {
11
+ const prefix = selected ? "► " : " ";
12
+ const displayLabel = suffix ? `${label} ${suffix}` : label;
13
+
14
+ const fg = selected ? SemanticColors.selectionText : SemanticColors.text;
15
+ const bg = selected ? SemanticColors.selectionBackground : undefined;
16
+
17
+ return (
18
+ <box flexDirection="column">
19
+ <text fg={fg} bg={bg} {...({ onClick: onActivate })}>
20
+ {prefix}{displayLabel}
21
+ </text>
22
+ {description ? (
23
+ <text fg={selected ? SemanticColors.text : SemanticColors.mutedText}>
24
+ {" "}{description}
25
+ </text>
26
+ ) : null}
27
+ </box>
28
+ );
29
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import type { OverlayProps } from "../../../semantic/types.ts";
3
+
4
+ export function Overlay({
5
+ zIndex = 10,
6
+ top,
7
+ left,
8
+ right,
9
+ bottom,
10
+ width,
11
+ height,
12
+ children,
13
+ }: OverlayProps & { children?: ReactNode }) {
14
+ return (
15
+ <box position="absolute" top={0} left={0} right={0} bottom={0} zIndex={zIndex}>
16
+ <box position="absolute" top={top as any} left={left as any} right={right as any} bottom={bottom as any} width={width as any} height={height as any}>
17
+ {children}
18
+ </box>
19
+ </box>
20
+ );
21
+ }
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from "react";
2
+ import type { PanelProps, Spacing } from "../../../semantic/types.ts";
3
+ import { SemanticColors } from "../../../theme.ts";
4
+
5
+ function normalizePadding(
6
+ padding: number | Spacing | undefined,
7
+ opts: { dense: boolean }
8
+ ): { padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number } {
9
+ if (padding === undefined) {
10
+ return opts.dense
11
+ ? {
12
+ padding: 0,
13
+ paddingLeft: 1,
14
+ paddingRight: 1,
15
+ }
16
+ : { padding: 1 };
17
+ }
18
+
19
+ if (typeof padding === "number") {
20
+ return { padding };
21
+ }
22
+
23
+ return {
24
+ paddingTop: padding.top ?? 0,
25
+ paddingRight: padding.right ?? 0,
26
+ paddingBottom: padding.bottom ?? 0,
27
+ paddingLeft: padding.left ?? 0,
28
+ };
29
+ }
30
+
31
+ export function Panel({
32
+ title,
33
+ focused,
34
+ border = true,
35
+ surface = "panel",
36
+ dense = false,
37
+ children,
38
+ flex,
39
+ width,
40
+ height,
41
+ flexDirection,
42
+ alignItems,
43
+ justifyContent,
44
+ gap,
45
+ padding,
46
+ noShrink,
47
+ }: PanelProps & { children?: ReactNode }) {
48
+ const backgroundColor = surface === "overlay" ? SemanticColors.overlay : SemanticColors.panelBackground;
49
+
50
+ const borderColor = surface === "overlay" ? SemanticColors.warning : focused ? SemanticColors.focusBorder : SemanticColors.border;
51
+
52
+ const resolvedPadding = normalizePadding(padding, { dense });
53
+
54
+ return (
55
+ <box
56
+ border={border}
57
+ borderStyle={border ? "rounded" : undefined}
58
+ borderColor={borderColor}
59
+ title={title}
60
+ padding={resolvedPadding.padding}
61
+ paddingTop={resolvedPadding.paddingTop}
62
+ paddingRight={resolvedPadding.paddingRight}
63
+ paddingBottom={resolvedPadding.paddingBottom}
64
+ paddingLeft={resolvedPadding.paddingLeft}
65
+ flexGrow={flex}
66
+ flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
67
+ width={width as any}
68
+ height={height as any}
69
+ flexDirection={flexDirection as any}
70
+ alignItems={alignItems as any}
71
+ justifyContent={justifyContent as any}
72
+ gap={gap}
73
+ backgroundColor={backgroundColor}
74
+ >
75
+ {children}
76
+ </box>
77
+ );
78
+ }
@@ -0,0 +1,85 @@
1
+ import { useRef, type ReactNode } from "react";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import type { ScrollViewProps, ScrollViewRef, Spacing } from "../../../semantic/types.ts";
4
+
5
+ function normalizePadding(padding: number | Spacing | undefined): any {
6
+ if (padding === undefined) {
7
+ return undefined;
8
+ }
9
+
10
+ if (typeof padding === "number") {
11
+ return padding;
12
+ }
13
+
14
+ return {
15
+ top: padding.top ?? 0,
16
+ right: padding.right ?? 0,
17
+ bottom: padding.bottom ?? 0,
18
+ left: padding.left ?? 0,
19
+ };
20
+ }
21
+
22
+ export function ScrollView({
23
+ axis = "vertical",
24
+ stickyToEnd,
25
+ focused,
26
+ scrollRef: onScrollRef,
27
+ children,
28
+ flex,
29
+ width,
30
+ height,
31
+ flexDirection,
32
+ alignItems,
33
+ justifyContent,
34
+ gap,
35
+ padding,
36
+ }: ScrollViewProps & { children?: ReactNode }) {
37
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
38
+
39
+ const imperativeApi: ScrollViewRef = {
40
+ scrollToTop: () => {
41
+ scrollRef.current?.scrollTo(0);
42
+ },
43
+ scrollToBottom: () => {
44
+ // No public "bottom" API in ScrollBoxRenderable; use large index.
45
+ scrollRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
46
+ },
47
+ scrollToIndex: (index: number) => {
48
+ scrollRef.current?.scrollTo(index);
49
+ },
50
+ };
51
+
52
+ // Provide the imperative API via callback.
53
+ if (onScrollRef) {
54
+ onScrollRef(imperativeApi);
55
+ }
56
+
57
+ const scrollY = axis === "vertical" || axis === "both";
58
+ const scrollX = axis === "horizontal" || axis === "both";
59
+
60
+ const resolvedStickyToEnd = stickyToEnd ? true : undefined;
61
+
62
+
63
+ return (
64
+ <scrollbox
65
+ ref={scrollRef}
66
+ scrollY={scrollY}
67
+ scrollX={scrollX}
68
+ focused={focused}
69
+ {...({ stickyToEnd: resolvedStickyToEnd })}
70
+ flexGrow={flex}
71
+ width={width as any}
72
+ height={height as any}
73
+ >
74
+ <box
75
+ flexDirection={flexDirection as any}
76
+ alignItems={alignItems as any}
77
+ justifyContent={justifyContent as any}
78
+ gap={gap}
79
+ padding={normalizePadding(padding)}
80
+ >
81
+ {children}
82
+ </box>
83
+ </scrollbox>
84
+ );
85
+ }
@@ -0,0 +1,59 @@
1
+ import type { SelectProps } from "../../../semantic/types.ts";
2
+ import type { SelectOption as OpenTuiSelectOption } from "@opentui/core";
3
+ import { SemanticColors } from "../../../theme.ts";
4
+
5
+ export function Select<TValue extends string>({
6
+ options,
7
+ value,
8
+ focused,
9
+ onChange,
10
+ onSubmit,
11
+ }: SelectProps) {
12
+ const selectedIndex = Math.max(
13
+ 0,
14
+ options.findIndex((opt) => opt.value === value)
15
+ );
16
+
17
+ return (
18
+ <select
19
+ options={
20
+ options.map(
21
+ (opt): OpenTuiSelectOption => ({
22
+ name: opt.label,
23
+ description: "",
24
+ value: opt.value,
25
+ })
26
+ )
27
+ }
28
+ selectedIndex={selectedIndex}
29
+ focused={focused}
30
+ onChange={(idx: number) => {
31
+ const next = options[idx];
32
+ if (next) {
33
+ onChange(next.value);
34
+ }
35
+ }}
36
+ onSelect={(idx: number, option: OpenTuiSelectOption | null) => {
37
+ if (option) {
38
+ onChange(option.value as TValue);
39
+ } else {
40
+ const next = options[idx];
41
+ if (next) {
42
+ onChange(next.value);
43
+ }
44
+ }
45
+
46
+ // Only submit when OpenTUI triggers selection (Enter).
47
+ // Arrow navigation uses onChange only.
48
+ onSubmit?.();
49
+ }}
50
+ showScrollIndicator={false}
51
+ showDescription={false}
52
+ height={Math.min(options.length, 10)}
53
+ width="100%"
54
+ wrapSelection={true}
55
+ selectedBackgroundColor={SemanticColors.focusBorder}
56
+ selectedTextColor={SemanticColors.inverseText}
57
+ />
58
+ );
59
+ }
@@ -0,0 +1,5 @@
1
+ import type { SpacerProps } from "../../../semantic/types.ts";
2
+
3
+ export function Spacer({ size, axis = "vertical" }: SpacerProps) {
4
+ return axis === "horizontal" ? <box width={size} flexShrink={0} /> : <box height={size} flexShrink={0} />;
5
+ }
@@ -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 { useState, useEffect, useMemo } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
 
3
3
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
4
  const SPINNER_INTERVAL = 80;
5
5
 
6
- export interface UseSpinnerResult {
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
  }