@pablozaiden/terminatui 0.2.0 → 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 +14 -2
  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 +6 -10
  6. package/examples/tui-app/commands/config/app/index.ts +2 -6
  7. package/examples/tui-app/commands/config/app/set.ts +23 -13
  8. package/examples/tui-app/commands/config/index.ts +2 -6
  9. package/examples/tui-app/commands/config/user/get.ts +6 -10
  10. package/examples/tui-app/commands/config/user/index.ts +2 -6
  11. package/examples/tui-app/commands/config/user/set.ts +6 -10
  12. package/examples/tui-app/commands/greet.ts +13 -11
  13. package/examples/tui-app/commands/math.ts +5 -9
  14. package/examples/tui-app/commands/status.ts +21 -12
  15. package/examples/tui-app/index.ts +6 -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 +14 -16
  23. package/guides/08-complete-application.md +12 -42
  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 +12 -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 +45 -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 -4
  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 -619
  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,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,160 @@
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
+ const nextValues = { ...values, [fieldKey]: value };
120
+ navigation.replace<ConfigParams>(ConfigScreen.Id, { ...params, values: nextValues });
121
+ navigation.closeModal();
122
+ },
123
+ onCancel: () => navigation.closeModal(),
124
+ });
125
+ }, [navigation, values, derivedFieldConfigs, params]);
126
+
127
+ // Handle opening the CLI Args modal
128
+ const handleShowCliArgs = useCallback(() => {
129
+ const cli = buildCliCommand(appName, commandPath, command.options, values as OptionValues<OptionSchema>);
130
+ navigation.openModal<CliModalParams>("cli", { command: cli });
131
+ }, [appName, commandPath, command.options, values, navigation]);
132
+
133
+ return (
134
+ <Container flexDirection="column" flex={1}>
135
+ <ConfigForm
136
+ title={`Configure: ${command.displayName ?? command.name}`}
137
+ fieldConfigs={derivedFieldConfigs}
138
+ values={values}
139
+ selectedIndex={selectedFieldIndex}
140
+ focused={true}
141
+ onSelectionChange={setSelectedFieldIndex}
142
+ onEditField={handleEditField}
143
+ onAction={handleRun}
144
+ additionalButtons={
145
+ command.supportsCli()
146
+ ? [{ label: "CLI Command", onPress: handleShowCliArgs }]
147
+ : []
148
+ }
149
+ actionButton={
150
+ <MenuButton
151
+ label={command.actionLabel ?? "Run"}
152
+ selected={selectedFieldIndex === derivedFieldConfigs.length + 1}
153
+ />
154
+ }
155
+ />
156
+ </Container>
157
+ );
158
+ };
159
+ }
160
+ }
@@ -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,60 @@
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
+
9
+ /**
10
+ * Screen state stored in navigation params.
11
+ */
12
+ export interface ResultsParams {
13
+ command: AnyCommand;
14
+ commandPath: string[];
15
+ values: Record<string, unknown>;
16
+ result: unknown;
17
+ }
18
+
19
+ export class ResultsScreen extends ScreenBase {
20
+ static readonly Id = "results";
21
+
22
+ getRoute(): string {
23
+ return ResultsScreen.Id;
24
+ }
25
+ /**
26
+ * Results screen - shows command execution results.
27
+ * Fully self-contained - gets all data from context and handles its own transitions.
28
+ */
29
+ override component(): ScreenComponent {
30
+ return function ResultsScreenComponent() {
31
+ const navigation = useNavigation();
32
+
33
+ // Get params from navigation
34
+ const params = navigation.current.params as ResultsParams | undefined;
35
+ if (!params) return null;
36
+
37
+ const { result, command } = params;
38
+
39
+ // Register clipboard provider for this screen
40
+ useClipboardProvider(
41
+ useCallback(() => {
42
+ if (command.getClipboardContent) {
43
+ const custom = command.getClipboardContent(result as CommandResult);
44
+ if (custom) return { content: custom, label: "Results" };
45
+ }
46
+ return { content: JSON.stringify(result, null, 2), label: "Results" };
47
+ }, [result, command])
48
+ );
49
+
50
+ return (
51
+ <ResultsPanel
52
+ result={result as CommandResult | null}
53
+ error={null}
54
+ focused={true}
55
+ renderResult={command.renderResult}
56
+ />
57
+ );
58
+ }
59
+ }
60
+ }
@@ -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={`Running ${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
+ }
@@ -0,0 +1,7 @@
1
+ import type { LabelProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function Label(props: LabelProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.Label(props);
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { MenuButtonProps } from "./types.ts";
2
+ import { useRenderer } from "../context/RendererContext.tsx";
3
+
4
+ export function MenuButton(props: MenuButtonProps) {
5
+ const renderer = useRenderer();
6
+ return renderer.components.MenuButton(props);
7
+ }