@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,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 { Theme } from "../theme.ts";
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
- // Keyboard handler for navigation
41
- useKeyboardHandler(
42
- (event) => {
43
- const { key } = event;
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
- // Arrow key navigation
46
- if (key.name === "down") {
47
- const newIndex = Math.min(selectedIndex + 1, commands.length - 1);
48
- onSelectionChange(newIndex);
49
- event.stopPropagation();
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
- // Enter to select command
61
- if (key.name === "return" || key.name === "enter") {
62
- const selected = commands[selectedIndex];
63
- if (selected) {
64
- onSelect(selected.command);
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
- // Escape to exit or go back
71
- if (key.name === "escape") {
72
- onExit();
73
- event.stopPropagation();
74
- return;
75
- }
76
- },
77
- KeyboardPriority.Focused
78
- );
64
+ return false;
65
+ });
79
66
 
80
67
  const title = breadcrumb?.length
81
68
  ? `Select Command (${breadcrumb.join(" › ")})`
82
69
  : "Select Command";
83
70
 
84
71
  return (
85
- <box
86
- flexDirection="column"
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
- paddingLeft={3}
99
- paddingRight={3}
100
- paddingTop={1}
101
- paddingBottom={1}
102
- minWidth={60}
76
+ padding={undefined}
77
+ width={60}
78
+ focused
103
79
  >
104
- <box flexDirection="column" gap={1}>
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
- <box key={item.command.name} flexDirection="column">
129
- <text fg={Theme.value}>
130
- {prefix}{label} {modeIndicator}
131
- </text>
132
- <text fg={Theme.border}>
133
- {" "}{description}
134
- </text>
135
- </box>
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
- </box>
139
- </box>
99
+ </Container>
100
+ </Panel>
140
101
 
141
- <text fg={Theme.label}>
142
- ↑↓ Navigate • Enter Select • Esc {breadcrumb?.length ? "Back" : "Exit"}
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]", "[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
- const cli = command.supportsCli();
153
- const tui = command.supportsTui();
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 type { ScrollBoxRenderable } from "@opentui/core";
3
- import { Theme } from "../theme.ts";
4
- import { FieldRow } from "./FieldRow.tsx";
5
- import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
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 borderColor = focused ? Theme.borderFocused : Theme.border;
61
- const scrollboxRef = useRef<ScrollBoxRenderable>(null);
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
- if (scrollboxRef.current) {
67
- scrollboxRef.current.scrollTo(selectedIndex);
68
- }
74
+ scrollViewRef.current?.scrollToIndex(selectedIndex);
69
75
  }, [selectedIndex]);
70
76
 
71
- // Handle keyboard events at Focused priority (only when focused)
72
- useKeyboardHandler(
73
- (event) => {
74
- const { key } = event;
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, totalFields - 1);
89
+ const newIndex = Math.min(selectedIndex + 1, totalItems - 1);
79
90
  onSelectionChange(newIndex);
80
- event.stopPropagation();
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
- event.stopPropagation();
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 === fieldConfigs.length) {
94
- onAction();
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
- event.stopPropagation();
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
- <box
111
- flexDirection="column"
112
- border={true}
113
- borderStyle="rounded"
114
- borderColor={borderColor}
125
+ <Panel
115
126
  title={title}
116
- flexGrow={1}
127
+ focused={focused}
128
+ flex={1}
117
129
  padding={1}
130
+ flexDirection="column"
118
131
  >
119
- <scrollbox
120
- ref={scrollboxRef}
121
- scrollY={true}
122
- flexGrow={1}
132
+ <ScrollView
133
+ axis="vertical"
134
+ flex={1}
135
+ scrollRef={(ref) => {
136
+ scrollViewRef.current = ref;
137
+ }}
123
138
  >
124
- <box flexDirection="column" gap={0}>
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
- <FieldRow
149
+ <Field
135
150
  key={field.key}
136
151
  label={field.label}
137
152
  value={displayValue}
138
- isSelected={isSelected}
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
- </box>
145
- </scrollbox>
146
- </box>
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 { Theme } from "../theme.ts";
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
- <box flexDirection="row" justifyContent="space-between" marginBottom={1}>
22
- <text fg={Theme.header}>
23
- <strong>{name}</strong>
24
- {breadcrumbStr}
25
- </text>
26
- <text fg={Theme.label}>
27
- v{version}
28
- </text>
29
- </box>
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 { Theme } from "../theme.ts";
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 = "key" | "string" | "number" | "boolean" | "null" | "punctuation";
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
- <box flexDirection="column">
112
+ <Container flexDirection="column" gap={0}>
119
113
  {lines.map((tokens, lineIdx) => (
120
- <text key={`json-${lineIdx}`}>
121
- {tokens.map((token, tokenIdx) => (
122
- <span key={tokenIdx} fg={TOKEN_COLORS[token.type]}>{token.value}</span>
123
- ))}
124
- </text>
114
+ <CodeHighlight
115
+ key={`json-${lineIdx}`}
116
+ tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
117
+ />
125
118
  ))}
126
- </box>
119
+ </Container>
127
120
  );
128
121
  }