@pablozaiden/terminatui 0.2.0 → 0.3.0

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 (181) hide show
  1. package/README.md +64 -43
  2. package/package.json +11 -8
  3. package/src/__tests__/application.test.ts +87 -68
  4. package/src/__tests__/buildCliCommand.test.ts +99 -119
  5. package/src/__tests__/builtins.test.ts +27 -75
  6. package/src/__tests__/command.test.ts +100 -131
  7. package/src/__tests__/configOnChange.test.ts +63 -0
  8. package/src/__tests__/context.test.ts +1 -26
  9. package/src/__tests__/helpCore.test.ts +227 -0
  10. package/src/__tests__/parser.test.ts +98 -244
  11. package/src/__tests__/registry.test.ts +33 -160
  12. package/src/__tests__/schemaToFields.test.ts +75 -158
  13. package/src/builtins/help.ts +12 -4
  14. package/src/builtins/settings.ts +18 -32
  15. package/src/builtins/version.ts +3 -3
  16. package/src/cli/output/colors.ts +1 -1
  17. package/src/cli/parser.ts +26 -95
  18. package/src/core/application.ts +192 -110
  19. package/src/core/command.ts +26 -9
  20. package/src/core/context.ts +31 -20
  21. package/src/core/help.ts +24 -18
  22. package/src/core/knownCommands.ts +13 -0
  23. package/src/core/logger.ts +39 -42
  24. package/src/core/registry.ts +5 -12
  25. package/src/index.ts +22 -137
  26. package/src/tui/TuiApplication.tsx +63 -120
  27. package/src/tui/TuiRoot.tsx +135 -0
  28. package/src/tui/adapters/factory.ts +19 -0
  29. package/src/tui/adapters/ink/InkRenderer.tsx +139 -0
  30. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  31. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  32. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  33. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  34. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  35. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  36. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  37. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  38. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  39. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  40. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  41. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  42. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  43. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  44. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  45. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  46. package/src/tui/adapters/ink/keyboard.ts +97 -0
  47. package/src/tui/adapters/ink/utils.ts +16 -0
  48. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +119 -0
  49. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  50. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  51. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  52. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  53. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  54. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  55. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  56. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  57. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  58. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  59. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  60. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  61. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  62. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  63. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  64. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  65. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  66. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  67. package/src/tui/adapters/types.ts +71 -0
  68. package/src/tui/components/ActionButton.tsx +0 -36
  69. package/src/tui/components/CommandSelector.tsx +45 -92
  70. package/src/tui/components/ConfigForm.tsx +68 -42
  71. package/src/tui/components/FieldRow.tsx +0 -30
  72. package/src/tui/components/Header.tsx +14 -13
  73. package/src/tui/components/JsonHighlight.tsx +10 -17
  74. package/src/tui/components/ModalBase.tsx +38 -0
  75. package/src/tui/components/ResultsPanel.tsx +27 -36
  76. package/src/tui/components/StatusBar.tsx +24 -39
  77. package/src/tui/components/logColors.ts +12 -0
  78. package/src/tui/context/ClipboardContext.tsx +87 -0
  79. package/src/tui/context/ExecutorContext.tsx +139 -0
  80. package/src/tui/context/KeyboardContext.tsx +85 -71
  81. package/src/tui/context/LogsContext.tsx +35 -0
  82. package/src/tui/context/NavigationContext.tsx +194 -0
  83. package/src/tui/context/RendererContext.tsx +20 -0
  84. package/src/tui/context/TuiAppContext.tsx +58 -0
  85. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  86. package/src/tui/hooks/useBackHandler.ts +34 -0
  87. package/src/tui/hooks/useClipboard.ts +40 -25
  88. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  89. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  90. package/src/tui/modals/CliModal.tsx +82 -0
  91. package/src/tui/modals/EditorModal.tsx +207 -0
  92. package/src/tui/modals/LogsModal.tsx +98 -0
  93. package/src/tui/registry.ts +102 -0
  94. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  95. package/src/tui/screens/ConfigScreen.tsx +165 -0
  96. package/src/tui/screens/ErrorScreen.tsx +58 -0
  97. package/src/tui/screens/ResultsScreen.tsx +68 -0
  98. package/src/tui/screens/RunningScreen.tsx +72 -0
  99. package/src/tui/screens/ScreenBase.ts +6 -0
  100. package/src/tui/semantic/Button.tsx +7 -0
  101. package/src/tui/semantic/Code.tsx +7 -0
  102. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  103. package/src/tui/semantic/Container.tsx +7 -0
  104. package/src/tui/semantic/Field.tsx +7 -0
  105. package/src/tui/semantic/Label.tsx +7 -0
  106. package/src/tui/semantic/MenuButton.tsx +7 -0
  107. package/src/tui/semantic/MenuItem.tsx +7 -0
  108. package/src/tui/semantic/Overlay.tsx +7 -0
  109. package/src/tui/semantic/Panel.tsx +7 -0
  110. package/src/tui/semantic/ScrollView.tsx +9 -0
  111. package/src/tui/semantic/Select.tsx +7 -0
  112. package/src/tui/semantic/Spacer.tsx +7 -0
  113. package/src/tui/semantic/Spinner.tsx +7 -0
  114. package/src/tui/semantic/TextInput.tsx +7 -0
  115. package/src/tui/semantic/Value.tsx +7 -0
  116. package/src/tui/semantic/types.ts +195 -0
  117. package/src/tui/theme.ts +25 -14
  118. package/src/tui/utils/buildCliCommand.ts +1 -0
  119. package/src/tui/utils/getEnumKeys.ts +3 -0
  120. package/src/tui/utils/parameterPersistence.ts +1 -0
  121. package/src/types/command.ts +0 -60
  122. package/.devcontainer/devcontainer.json +0 -19
  123. package/.devcontainer/install-prerequisites.sh +0 -49
  124. package/.github/workflows/copilot-setup-steps.yml +0 -32
  125. package/.github/workflows/pull-request.yml +0 -27
  126. package/.github/workflows/release-npm-package.yml +0 -81
  127. package/AGENTS.md +0 -31
  128. package/bun.lock +0 -236
  129. package/examples/tui-app/commands/config/app/get.ts +0 -66
  130. package/examples/tui-app/commands/config/app/index.ts +0 -27
  131. package/examples/tui-app/commands/config/app/set.ts +0 -86
  132. package/examples/tui-app/commands/config/index.ts +0 -32
  133. package/examples/tui-app/commands/config/user/get.ts +0 -65
  134. package/examples/tui-app/commands/config/user/index.ts +0 -27
  135. package/examples/tui-app/commands/config/user/set.ts +0 -61
  136. package/examples/tui-app/commands/greet.ts +0 -76
  137. package/examples/tui-app/commands/index.ts +0 -4
  138. package/examples/tui-app/commands/math.ts +0 -115
  139. package/examples/tui-app/commands/status.ts +0 -77
  140. package/examples/tui-app/index.ts +0 -35
  141. package/guides/01-hello-world.md +0 -96
  142. package/guides/02-adding-options.md +0 -103
  143. package/guides/03-multiple-commands.md +0 -163
  144. package/guides/04-subcommands.md +0 -206
  145. package/guides/05-interactive-tui.md +0 -194
  146. package/guides/06-config-validation.md +0 -264
  147. package/guides/07-async-cancellation.md +0 -336
  148. package/guides/08-complete-application.md +0 -537
  149. package/guides/README.md +0 -74
  150. package/src/__tests__/colors.test.ts +0 -127
  151. package/src/__tests__/commandClass.test.ts +0 -130
  152. package/src/__tests__/help.test.ts +0 -412
  153. package/src/__tests__/registryNew.test.ts +0 -160
  154. package/src/__tests__/table.test.ts +0 -146
  155. package/src/__tests__/tui.test.ts +0 -26
  156. package/src/builtins/index.ts +0 -4
  157. package/src/cli/help.ts +0 -174
  158. package/src/cli/index.ts +0 -3
  159. package/src/cli/output/index.ts +0 -2
  160. package/src/cli/output/table.ts +0 -141
  161. package/src/commands/help.ts +0 -50
  162. package/src/commands/index.ts +0 -1
  163. package/src/components/index.ts +0 -147
  164. package/src/core/index.ts +0 -15
  165. package/src/hooks/index.ts +0 -131
  166. package/src/registry/commandRegistry.ts +0 -77
  167. package/src/registry/index.ts +0 -1
  168. package/src/tui/TuiApp.tsx +0 -619
  169. package/src/tui/app.ts +0 -29
  170. package/src/tui/components/CliModal.tsx +0 -81
  171. package/src/tui/components/EditorModal.tsx +0 -177
  172. package/src/tui/components/LogsPanel.tsx +0 -86
  173. package/src/tui/components/index.ts +0 -13
  174. package/src/tui/context/index.ts +0 -7
  175. package/src/tui/hooks/index.ts +0 -35
  176. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  177. package/src/tui/hooks/useLogStream.ts +0 -96
  178. package/src/tui/index.ts +0 -65
  179. package/src/tui/utils/index.ts +0 -13
  180. package/src/types/index.ts +0 -1
  181. package/tsconfig.json +0 -25
@@ -0,0 +1,102 @@
1
+ import type { ReactNode } from "react";
2
+ import { CliModal } from "./modals/CliModal.tsx";
3
+ import { EditorModal } from "./modals/EditorModal.tsx";
4
+ import { LogsModal } from "./modals/LogsModal.tsx";
5
+ import type { ScreenBase } from "./screens/ScreenBase";
6
+ import { CommandSelectScreen } from "./screens/CommandSelectScreen";
7
+ import { ConfigScreen } from "./screens/ConfigScreen";
8
+ import { ErrorScreen } from "./screens/ErrorScreen";
9
+ import { ResultsScreen } from "./screens/ResultsScreen";
10
+ import { RunningScreen } from "./screens/RunningScreen";
11
+
12
+ /**
13
+ * Screen component type.
14
+ * Screens receive no props - they get everything from context.
15
+ */
16
+ export type ScreenComponent = () => ReactNode;
17
+
18
+ /**
19
+ * Modal component type.
20
+ * Modals receive their params and a close function.
21
+ */
22
+ export type ModalComponent<TParams> = (props: {
23
+ params: TParams;
24
+ onClose: () => void;
25
+ }) => ReactNode;
26
+
27
+
28
+ /**
29
+ * Screen registry - maps route names to screen components.
30
+ */
31
+ const screenRegistry = new Map<string, ScreenComponent>();
32
+
33
+ /**
34
+ * Modal registry - maps modal IDs to modal components.
35
+ */
36
+ const modalRegistry = new Map<string, ModalComponent<any>>();
37
+
38
+ /**
39
+ * Register a screen component for a route.
40
+ * Typically called at module load time.
41
+ */
42
+
43
+ export function registerScreen(screen: ScreenBase): void {
44
+ screenRegistry.set(screen.getRoute(), screen.component());
45
+ }
46
+
47
+ /**
48
+ * Register a modal component for a modal ID.
49
+ * Typically called at module load time.
50
+ */
51
+ export interface ModalDefinition<TParams> {
52
+ getId(): string;
53
+ component(): ModalComponent<TParams>;
54
+ }
55
+
56
+ export function registerModal<TParams>(modal: ModalDefinition<TParams>): void {
57
+ modalRegistry.set(modal.getId(), modal.component());
58
+ }
59
+
60
+ /**
61
+ * Get a screen component by route.
62
+ * Returns undefined if not registered.
63
+ */
64
+ export function getScreen(route: string): ScreenComponent | undefined {
65
+ return screenRegistry.get(route);
66
+ }
67
+
68
+ /**
69
+ * Get a modal component by ID.
70
+ * Returns undefined if not registered.
71
+ */
72
+ export function getModal<TParams>(id: string): ModalComponent<TParams> | undefined {
73
+ return modalRegistry.get(id);
74
+ }
75
+
76
+ /**
77
+ * Get all registered screen routes.
78
+ */
79
+ export function getRegisteredScreens(): string[] {
80
+ return Array.from(screenRegistry.keys());
81
+ }
82
+
83
+ /**
84
+ * Get all registered modal IDs.
85
+ */
86
+ export function getRegisteredModals(): string[] {
87
+ return Array.from(modalRegistry.keys());
88
+ }
89
+
90
+ export function registerAllScreens(): void {
91
+ registerScreen(new CommandSelectScreen());
92
+ registerScreen(new ConfigScreen());
93
+ registerScreen(new RunningScreen());
94
+ registerScreen(new ResultsScreen());
95
+ registerScreen(new ErrorScreen());
96
+ }
97
+
98
+ export function registerAllModals(): void {
99
+ registerModal(new EditorModal());
100
+ registerModal(new CliModal());
101
+ registerModal(new LogsModal());
102
+ }
@@ -0,0 +1,162 @@
1
+ import { useState, useMemo, useCallback } from "react";
2
+ import type { AnyCommand } from "../../core/command.ts";
3
+ import { CommandSelector } from "../components/CommandSelector.tsx";
4
+ import { useTuiApp } from "../context/TuiAppContext.tsx";
5
+ import { useNavigation } from "../context/NavigationContext.tsx";
6
+ import { useBackHandler } from "../hooks/useBackHandler.ts";
7
+ import type { ScreenComponent } from "../registry.ts";
8
+ import { loadPersistedParameters } from "../utils/parameterPersistence.ts";
9
+ import { schemaToFieldConfigs } from "../utils/schemaToFields.ts";
10
+ import type { OptionDef, OptionSchema } from "../../types/command.ts";
11
+ import { ScreenBase } from "./ScreenBase.ts";
12
+ import { type ConfigParams, ConfigScreen } from "./ConfigScreen.tsx";
13
+
14
+ /**
15
+ * Screen state stored in navigation params.
16
+ */
17
+ export interface CommandSelectParams {
18
+ commandPath: string[];
19
+ }
20
+
21
+ /**
22
+ * Command selection screen.
23
+ * Fully self-contained - gets all data from context and handles its own transitions.
24
+ */
25
+ export class CommandSelectScreen extends ScreenBase {
26
+ static readonly Id = "command-select";
27
+
28
+ getRoute(): string {
29
+ return CommandSelectScreen.Id;
30
+ }
31
+
32
+ override component(): ScreenComponent {
33
+ return function CommandSelectScreenComponent() {
34
+ const { name, commands } = useTuiApp();
35
+ const navigation = useNavigation();
36
+
37
+ // Get params from navigation, with defaults
38
+ const params = (navigation.current.params ?? { commandPath: [] }) as CommandSelectParams;
39
+ const commandPath = params.commandPath ?? [];
40
+
41
+ // Local selection state
42
+ const [selectedIndex, setSelectedIndex] = useState(0);
43
+
44
+ // Get current commands based on path
45
+ const currentCommands = useMemo<AnyCommand[]>(() => {
46
+ if (commandPath.length === 0) {
47
+ return commands.filter((cmd) => cmd.supportsTui());
48
+ }
49
+
50
+ let current: AnyCommand[] = commands;
51
+ for (const pathPart of commandPath) {
52
+ const found = current.find((c) => c.name === pathPart);
53
+ if (found?.subCommands) {
54
+ current = found.subCommands.filter((sub) => sub.supportsTui());
55
+ } else {
56
+ break;
57
+ }
58
+ }
59
+ return current;
60
+ }, [commands, commandPath]);
61
+
62
+ // Build breadcrumb from path
63
+ const breadcrumb = useMemo(() => {
64
+ if (commandPath.length === 0) return undefined;
65
+
66
+ const displayNames: string[] = [];
67
+ let current: AnyCommand[] = commands;
68
+
69
+ for (const pathPart of commandPath) {
70
+ const found = current.find((c) => c.name === pathPart);
71
+ if (found) {
72
+ displayNames.push(found.displayName ?? found.name);
73
+ if (found.subCommands) {
74
+ current = found.subCommands;
75
+ }
76
+ } else {
77
+ displayNames.push(pathPart);
78
+ }
79
+ }
80
+
81
+ return displayNames;
82
+ }, [commandPath, commands]);
83
+
84
+ const items = currentCommands.map((cmd) => ({
85
+ command: cmd,
86
+ label: cmd.displayName ?? cmd.name,
87
+ description: cmd.description,
88
+ }));
89
+
90
+ // Handle command selection - this screen decides where to go next
91
+ const handleSelect = useCallback((cmd: AnyCommand) => {
92
+ // If command has runnable subcommands, navigate deeper
93
+ if (cmd.subCommands && cmd.subCommands.some((c) => c.supportsTui())) {
94
+ navigation.replace<CommandSelectParams>(CommandSelectScreen.Id, { commandPath: [...commandPath, cmd.name] });
95
+ return;
96
+ }
97
+
98
+ // Otherwise, push to config screen
99
+ navigation.push<ConfigParams>(ConfigScreen.Id, {
100
+ command: cmd,
101
+ commandPath: [...commandPath, cmd.name],
102
+ values: initializeConfigValues(name, cmd),
103
+ fieldConfigs: schemaToFieldConfigs(cmd.options),
104
+ });
105
+ }, [navigation, commandPath, name]);
106
+
107
+ // Register back handler - this screen decides what back means
108
+ useBackHandler(useCallback(() => {
109
+ if (commandPath.length > 0) {
110
+ // Go up one level
111
+ navigation.replace<CommandSelectParams>(CommandSelectScreen.Id, { commandPath: commandPath.slice(0, -1) });
112
+ return true; // We handled it
113
+ }
114
+ // At root - let navigation call onExit
115
+ return false;
116
+ }, [navigation, commandPath]));
117
+
118
+ return (
119
+ <CommandSelector
120
+ commands={items}
121
+ selectedIndex={selectedIndex}
122
+ onSelectionChange={setSelectedIndex}
123
+ onSelect={handleSelect}
124
+ breadcrumb={breadcrumb}
125
+ />
126
+ );
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Initialize config values from defaults and persisted values.
133
+ */
134
+ function initializeConfigValues(appName: string, cmd: AnyCommand): Record<string, unknown> {
135
+ const defaults: Record<string, unknown> = {};
136
+ const optionDefs = cmd.options as OptionSchema;
137
+
138
+ for (const [key, def] of Object.entries(optionDefs)) {
139
+ const typedDef = def as OptionDef;
140
+ if (typedDef.default !== undefined) {
141
+ defaults[key] = typedDef.default;
142
+ } else {
143
+ switch (typedDef.type) {
144
+ case "string":
145
+ defaults[key] = typedDef.enum?.[0] ?? "";
146
+ break;
147
+ case "number":
148
+ defaults[key] = typedDef.min ?? 0;
149
+ break;
150
+ case "boolean":
151
+ defaults[key] = false;
152
+ break;
153
+ case "array":
154
+ defaults[key] = [];
155
+ break;
156
+ }
157
+ }
158
+ }
159
+
160
+ const persisted = loadPersistedParameters(appName, cmd.name);
161
+ return { ...defaults, ...persisted };
162
+ }
@@ -0,0 +1,165 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import type { AnyCommand } from "../../core/command.ts";
3
+ import type { FieldConfig } from "../components/types.ts";
4
+ import { ConfigForm } from "../components/ConfigForm.tsx";
5
+ import { Container } from "../semantic/Container.tsx";
6
+ import { MenuButton } from "../semantic/MenuButton.tsx";
7
+ import { schemaToFieldConfigs } from "../utils/schemaToFields.ts";
8
+ import { useClipboardProvider } from "../hooks/useClipboardProvider.ts";
9
+ import { buildCliCommand } from "../utils/buildCliCommand.ts";
10
+ import { useTuiApp } from "../context/TuiAppContext.tsx";
11
+ import { useNavigation } from "../context/NavigationContext.tsx";
12
+ import { useExecutor } from "../context/ExecutorContext.tsx";
13
+ import type { ScreenComponent } from "../registry.ts";
14
+ import { savePersistedParameters } from "../utils/parameterPersistence.ts";
15
+ import type { OptionSchema, OptionValues } from "../../types/command.ts";
16
+ import { ScreenBase } from "./ScreenBase.ts";
17
+ import type { EditorModalParams } from "../modals/EditorModal.tsx";
18
+ import type { CliModalParams } from "../modals/CliModal.tsx";
19
+ import { RunningScreen, type RunningParams } from "./RunningScreen.tsx";
20
+ import { type ErrorParams, ErrorScreen } from "./ErrorScreen.tsx";
21
+ import { type ResultsParams, ResultsScreen } from "./ResultsScreen.tsx";
22
+
23
+ /**
24
+ * Screen state stored in navigation params.
25
+ */
26
+ export interface ConfigParams {
27
+ command: AnyCommand;
28
+ commandPath: string[];
29
+ values: Record<string, unknown>;
30
+ fieldConfigs: FieldConfig[];
31
+ }
32
+
33
+ /**
34
+ * Config screen for editing command options before execution.
35
+ * Fully self-contained - gets all data from context and handles its own transitions.
36
+ */
37
+ export class ConfigScreen extends ScreenBase {
38
+ static readonly Id = "config";
39
+ getRoute(): string {
40
+ return ConfigScreen.Id;
41
+ }
42
+
43
+ override component(): ScreenComponent {
44
+ return function ConfigScreenComponent() {
45
+ const { name: appName } = useTuiApp();
46
+ const navigation = useNavigation();
47
+ const executor = useExecutor();
48
+
49
+ // Get params from navigation
50
+ const params = navigation.current.params as ConfigParams | undefined;
51
+ if (!params) return null;
52
+
53
+ const { command, commandPath, values, fieldConfigs } = params;
54
+
55
+ // Local selection state for the form
56
+ const [selectedFieldIndex, setSelectedFieldIndex] = useState(0);
57
+
58
+ // Derive field configs (in case they weren't passed)
59
+ const derivedFieldConfigs = useMemo(
60
+ () => fieldConfigs ?? schemaToFieldConfigs(command.options),
61
+ [fieldConfigs, command.options]
62
+ );
63
+
64
+ // Register clipboard provider for this screen
65
+ useClipboardProvider(
66
+ useCallback(() => ({
67
+ content: JSON.stringify(values, null, 2),
68
+ label: "Config",
69
+ }), [values])
70
+ );
71
+
72
+ // Handle running the command
73
+ const handleRun = useCallback(async () => {
74
+ // Save parameters for next time
75
+ savePersistedParameters(appName, command.name, values);
76
+
77
+ // Push to running screen
78
+ navigation.push<RunningParams>(RunningScreen.Id, {
79
+ command,
80
+ commandPath,
81
+ values,
82
+ });
83
+
84
+ // Execute the command
85
+ const outcome = await executor.execute(command, values);
86
+
87
+ if (outcome.cancelled) {
88
+ // If cancelled, pop back to config
89
+ navigation.pop();
90
+ return;
91
+ }
92
+
93
+ if (outcome.success) {
94
+ // Replace running with results
95
+ navigation.replace<ResultsParams>(ResultsScreen.Id, {
96
+ command,
97
+ commandPath,
98
+ values,
99
+ result: outcome.result ?? null,
100
+ });
101
+ } else {
102
+ // Replace running with error
103
+ navigation.replace<ErrorParams>(ErrorScreen.Id, {
104
+ command,
105
+ commandPath,
106
+ values,
107
+ error: outcome.error ?? new Error("Unknown error"),
108
+ });
109
+ }
110
+ }, [appName, command, commandPath, values, navigation, executor]);
111
+
112
+ // Handle editing a field - open property editor modal
113
+ const handleEditField = useCallback((fieldKey: string) => {
114
+ navigation.openModal<EditorModalParams>("property-editor", {
115
+ fieldKey,
116
+ currentValue: values[fieldKey],
117
+ fieldConfigs: derivedFieldConfigs,
118
+ onSubmit: (value: unknown) => {
119
+ let nextValues = { ...values, [fieldKey]: value };
120
+ const updates = command.onConfigChange?.(fieldKey, value, nextValues);
121
+ if (updates) {
122
+ nextValues = { ...nextValues, ...updates };
123
+ }
124
+
125
+ navigation.replace<ConfigParams>(ConfigScreen.Id, { ...params, values: nextValues });
126
+ navigation.closeModal();
127
+ },
128
+ onCancel: () => navigation.closeModal(),
129
+ });
130
+ }, [navigation, values, derivedFieldConfigs, params]);
131
+
132
+ // Handle opening the CLI Args modal
133
+ const handleShowCliArgs = useCallback(() => {
134
+ const cli = buildCliCommand(appName, commandPath, command.options, values as OptionValues<OptionSchema>);
135
+ navigation.openModal<CliModalParams>("cli", { command: cli });
136
+ }, [appName, commandPath, command.options, values, navigation]);
137
+
138
+ return (
139
+ <Container flexDirection="column" flex={1}>
140
+ <ConfigForm
141
+ title={`Configure: ${command.displayName ?? command.name}`}
142
+ fieldConfigs={derivedFieldConfigs}
143
+ values={values}
144
+ selectedIndex={selectedFieldIndex}
145
+ focused={true}
146
+ onSelectionChange={setSelectedFieldIndex}
147
+ onEditField={handleEditField}
148
+ onAction={handleRun}
149
+ additionalButtons={
150
+ command.supportsCli()
151
+ ? [{ label: "CLI Command", onPress: handleShowCliArgs }]
152
+ : []
153
+ }
154
+ actionButton={
155
+ <MenuButton
156
+ label={command.actionLabel ?? "Run"}
157
+ selected={selectedFieldIndex === derivedFieldConfigs.length + 1}
158
+ />
159
+ }
160
+ />
161
+ </Container>
162
+ );
163
+ };
164
+ }
165
+ }
@@ -0,0 +1,58 @@
1
+ import { useCallback } from "react";
2
+ import type { AnyCommand } from "../../core/command.ts";
3
+ import { useNavigation } from "../context/NavigationContext.tsx";
4
+ import { ResultsPanel } from "../components/ResultsPanel.tsx";
5
+ import { useClipboardProvider } from "../hooks/useClipboardProvider.ts";
6
+ import type { ScreenComponent } from "../registry.ts";
7
+ import { ScreenBase } from "./ScreenBase.ts";
8
+
9
+ /**
10
+ * Screen state stored in navigation params.
11
+ */
12
+ export interface ErrorParams {
13
+ command: AnyCommand;
14
+ commandPath: string[];
15
+ values: Record<string, unknown>;
16
+ error: Error;
17
+ }
18
+
19
+ /**
20
+ * Error screen - shows command execution errors.
21
+ * Fully self-contained - gets all data from context and handles its own transitions.
22
+ */
23
+ export class ErrorScreen extends ScreenBase {
24
+ static readonly Id = "error";
25
+
26
+ getRoute(): string {
27
+ return ErrorScreen.Id;
28
+ }
29
+
30
+ override component(): ScreenComponent {
31
+ return function ErrorScreenComponent() {
32
+ const navigation = useNavigation();
33
+
34
+ // Get params from navigation
35
+ const params = navigation.current.params as ErrorParams | undefined;
36
+ if (!params) return null;
37
+
38
+ const { error, command } = params;
39
+
40
+ // Register clipboard provider for this screen
41
+ useClipboardProvider(
42
+ useCallback(() => ({
43
+ content: error.message,
44
+ label: "Error",
45
+ }), [error])
46
+ );
47
+
48
+ return (
49
+ <ResultsPanel
50
+ result={null}
51
+ error={error}
52
+ focused={true}
53
+ renderResult={command.renderResult}
54
+ />
55
+ );
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback } from "react";
2
+ import type { AnyCommand, CommandResult } from "../../core/command.ts";
3
+ import { useNavigation } from "../context/NavigationContext.tsx";
4
+ import { ResultsPanel } from "../components/ResultsPanel.tsx";
5
+ import { useClipboardProvider } from "../hooks/useClipboardProvider.ts";
6
+ import { type ScreenComponent } from "../registry.ts";
7
+ import { ScreenBase } from "./ScreenBase.ts";
8
+ import { useRenderer } from "../context/RendererContext.tsx";
9
+
10
+ /**
11
+ * Screen state stored in navigation params.
12
+ */
13
+ export interface ResultsParams {
14
+ command: AnyCommand;
15
+ commandPath: string[];
16
+ values: Record<string, unknown>;
17
+ result: unknown;
18
+ }
19
+
20
+ export class ResultsScreen extends ScreenBase {
21
+ static readonly Id = "results";
22
+
23
+ getRoute(): string {
24
+ return ResultsScreen.Id;
25
+ }
26
+ /**
27
+ * Results screen - shows command execution results.
28
+ * Fully self-contained - gets all data from context and handles its own transitions.
29
+ */
30
+ override component(): ScreenComponent {
31
+ return function ResultsScreenComponent() {
32
+ const navigation = useNavigation();
33
+
34
+ // Get params from navigation
35
+ const params = navigation.current.params as ResultsParams | undefined;
36
+ if (!params) return null;
37
+
38
+ const { result, command } = params;
39
+
40
+ const renderer = useRenderer();
41
+
42
+ let renderFunction = undefined;
43
+ if (renderer.supportCustomRendering()) {
44
+ renderFunction = command.renderResult;
45
+ }
46
+
47
+ // Register clipboard provider for this screen
48
+ useClipboardProvider(
49
+ useCallback(() => {
50
+ if (command.getClipboardContent) {
51
+ const custom = command.getClipboardContent(result as CommandResult);
52
+ if (custom) return { content: custom, label: "Results" };
53
+ }
54
+ return { content: JSON.stringify(result, null, 2), label: "Results" };
55
+ }, [result, command])
56
+ );
57
+
58
+ return (
59
+ <ResultsPanel
60
+ result={result as CommandResult | null}
61
+ error={null}
62
+ focused={true}
63
+ renderResult={renderFunction}
64
+ />
65
+ );
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,72 @@
1
+ import { useCallback } from "react";
2
+ import type { AnyCommand } from "../../core/command.ts";
3
+ import { useNavigation } from "../context/NavigationContext.tsx";
4
+ import { useExecutor } from "../context/ExecutorContext.tsx";
5
+ import { useBackHandler } from "../hooks/useBackHandler.ts";
6
+ import type { ScreenComponent } from "../registry.ts";
7
+ import { ScreenBase } from "./ScreenBase.ts";
8
+ import { Container } from "../semantic/Container.tsx";
9
+ import { Label } from "../semantic/Label.tsx";
10
+ import { Panel } from "../semantic/Panel.tsx";
11
+
12
+ /**
13
+ * Screen state stored in navigation params.
14
+ */
15
+ export interface RunningParams {
16
+ command: AnyCommand;
17
+ commandPath: string[];
18
+ values: Record<string, unknown>;
19
+ }
20
+
21
+ /**
22
+ * Running screen - shows while a command is executing.
23
+ * Fully self-contained - gets all data from context and handles its own transitions.
24
+ */
25
+ export class RunningScreen extends ScreenBase {
26
+ static readonly Id = "running";
27
+ getRoute(): string {
28
+ return RunningScreen.Id;
29
+ }
30
+
31
+ override component(): ScreenComponent {
32
+ return function RunningScreenComponent() {
33
+ const navigation = useNavigation();
34
+ const executor = useExecutor();
35
+
36
+ // Get params from navigation
37
+ const params = navigation.current.params as RunningParams | undefined;
38
+ if (!params) return null;
39
+
40
+ const { command } = params;
41
+
42
+ // Register back handler - cancel execution on back
43
+ useBackHandler(useCallback(() => {
44
+ if (executor.isExecuting) {
45
+ executor.cancel();
46
+ executor.reset();
47
+ // Pop back to config screen
48
+ navigation.pop();
49
+ return true;
50
+ }
51
+ return false;
52
+ }, [executor, navigation]));
53
+
54
+ return (
55
+ <Panel
56
+ flexDirection="column"
57
+ flex={1}
58
+ title={`${command.displayName ?? command.name}`}
59
+ padding={1}
60
+ focused
61
+ >
62
+ <Container flexDirection="column" flex={1} gap={1}>
63
+ <Label color="mutedText">
64
+ Check logs for progress.
65
+ </Label>
66
+ <Label color="mutedText">Press Esc to cancel.</Label>
67
+ </Container>
68
+ </Panel>
69
+ );
70
+ };
71
+ }
72
+ }
@@ -0,0 +1,6 @@
1
+ import type { ScreenComponent } from "../registry";
2
+
3
+ export abstract class ScreenBase {
4
+ abstract component(): ScreenComponent;
5
+ abstract getRoute(): string;
6
+ }
@@ -0,0 +1,7 @@
1
+ import type { ButtonProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function Button(props: ButtonProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.Button(props);
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { CodeProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function Code(props: CodeProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.Code(props);
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { CodeHighlightProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function CodeHighlight(props: CodeHighlightProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.CodeHighlight(props);
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { ContainerProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function Container(props: ContainerProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.Container(props);
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { FieldProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function Field(props: FieldProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.Field(props);
7
+ }