@pablozaiden/terminatui 0.1.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 (95) hide show
  1. package/.devcontainer/devcontainer.json +19 -0
  2. package/.devcontainer/install-prerequisites.sh +49 -0
  3. package/.github/workflows/copilot-setup-steps.yml +32 -0
  4. package/.github/workflows/pull-request.yml +27 -0
  5. package/.github/workflows/release-npm-package.yml +78 -0
  6. package/LICENSE +21 -0
  7. package/README.md +524 -0
  8. package/examples/tui-app/commands/greet.ts +75 -0
  9. package/examples/tui-app/commands/index.ts +3 -0
  10. package/examples/tui-app/commands/math.ts +114 -0
  11. package/examples/tui-app/commands/status.ts +75 -0
  12. package/examples/tui-app/index.ts +34 -0
  13. package/guides/01-hello-world.md +96 -0
  14. package/guides/02-adding-options.md +103 -0
  15. package/guides/03-multiple-commands.md +163 -0
  16. package/guides/04-subcommands.md +206 -0
  17. package/guides/05-interactive-tui.md +194 -0
  18. package/guides/06-config-validation.md +264 -0
  19. package/guides/07-async-cancellation.md +388 -0
  20. package/guides/08-complete-application.md +673 -0
  21. package/guides/README.md +74 -0
  22. package/package.json +32 -0
  23. package/src/__tests__/application.test.ts +425 -0
  24. package/src/__tests__/buildCliCommand.test.ts +125 -0
  25. package/src/__tests__/builtins.test.ts +133 -0
  26. package/src/__tests__/colors.test.ts +127 -0
  27. package/src/__tests__/command.test.ts +157 -0
  28. package/src/__tests__/commandClass.test.ts +130 -0
  29. package/src/__tests__/context.test.ts +97 -0
  30. package/src/__tests__/help.test.ts +412 -0
  31. package/src/__tests__/parser.test.ts +268 -0
  32. package/src/__tests__/registry.test.ts +195 -0
  33. package/src/__tests__/registryNew.test.ts +160 -0
  34. package/src/__tests__/schemaToFields.test.ts +176 -0
  35. package/src/__tests__/table.test.ts +146 -0
  36. package/src/__tests__/tui.test.ts +26 -0
  37. package/src/builtins/help.ts +85 -0
  38. package/src/builtins/index.ts +4 -0
  39. package/src/builtins/settings.ts +106 -0
  40. package/src/builtins/version.ts +72 -0
  41. package/src/cli/help.ts +174 -0
  42. package/src/cli/index.ts +3 -0
  43. package/src/cli/output/colors.ts +74 -0
  44. package/src/cli/output/index.ts +2 -0
  45. package/src/cli/output/table.ts +141 -0
  46. package/src/cli/parser.ts +241 -0
  47. package/src/commands/help.ts +50 -0
  48. package/src/commands/index.ts +1 -0
  49. package/src/components/index.ts +147 -0
  50. package/src/core/application.ts +461 -0
  51. package/src/core/command.ts +269 -0
  52. package/src/core/context.ts +112 -0
  53. package/src/core/help.ts +214 -0
  54. package/src/core/index.ts +15 -0
  55. package/src/core/logger.ts +164 -0
  56. package/src/core/registry.ts +140 -0
  57. package/src/hooks/index.ts +131 -0
  58. package/src/index.ts +137 -0
  59. package/src/registry/commandRegistry.ts +77 -0
  60. package/src/registry/index.ts +1 -0
  61. package/src/tui/TuiApp.tsx +582 -0
  62. package/src/tui/TuiApplication.tsx +230 -0
  63. package/src/tui/app.ts +29 -0
  64. package/src/tui/components/ActionButton.tsx +36 -0
  65. package/src/tui/components/CliModal.tsx +81 -0
  66. package/src/tui/components/CommandSelector.tsx +159 -0
  67. package/src/tui/components/ConfigForm.tsx +148 -0
  68. package/src/tui/components/EditorModal.tsx +177 -0
  69. package/src/tui/components/FieldRow.tsx +30 -0
  70. package/src/tui/components/Header.tsx +31 -0
  71. package/src/tui/components/JsonHighlight.tsx +128 -0
  72. package/src/tui/components/LogsPanel.tsx +86 -0
  73. package/src/tui/components/ResultsPanel.tsx +93 -0
  74. package/src/tui/components/StatusBar.tsx +59 -0
  75. package/src/tui/components/index.ts +13 -0
  76. package/src/tui/components/types.ts +30 -0
  77. package/src/tui/context/KeyboardContext.tsx +118 -0
  78. package/src/tui/context/index.ts +7 -0
  79. package/src/tui/hooks/index.ts +35 -0
  80. package/src/tui/hooks/useClipboard.ts +66 -0
  81. package/src/tui/hooks/useCommandExecutor.ts +131 -0
  82. package/src/tui/hooks/useConfigState.ts +171 -0
  83. package/src/tui/hooks/useKeyboardHandler.ts +91 -0
  84. package/src/tui/hooks/useLogStream.ts +96 -0
  85. package/src/tui/hooks/useSpinner.ts +46 -0
  86. package/src/tui/index.ts +65 -0
  87. package/src/tui/theme.ts +21 -0
  88. package/src/tui/utils/buildCliCommand.ts +90 -0
  89. package/src/tui/utils/index.ts +13 -0
  90. package/src/tui/utils/parameterPersistence.ts +96 -0
  91. package/src/tui/utils/schemaToFields.ts +144 -0
  92. package/src/types/command.ts +103 -0
  93. package/src/types/execution.ts +11 -0
  94. package/src/types/index.ts +1 -0
  95. package/tsconfig.json +25 -0
@@ -0,0 +1,241 @@
1
+ import type { Command, OptionSchema, OptionValues } from "../types/command.ts";
2
+ import { parseArgs, type ParseArgsConfig } from "util";
3
+
4
+ /**
5
+ * Result of parsing CLI arguments
6
+ */
7
+ export interface ParseResult<T extends OptionSchema = OptionSchema> {
8
+ command: Command<T> | null;
9
+ commandPath: string[];
10
+ options: OptionValues<T>;
11
+ args: string[];
12
+ showHelp: boolean;
13
+ error?: ParseError;
14
+ }
15
+
16
+ /**
17
+ * Error during parsing
18
+ */
19
+ export interface ParseError {
20
+ type: "unknown_command" | "invalid_option" | "missing_required" | "validation";
21
+ message: string;
22
+ field?: string;
23
+ }
24
+
25
+ /**
26
+ * Extract command chain from args (commands before flags)
27
+ */
28
+ export function extractCommandChain(args: string[]): {
29
+ commands: string[];
30
+ remaining: string[];
31
+ } {
32
+ const commands: string[] = [];
33
+ let i = 0;
34
+
35
+ for (; i < args.length; i++) {
36
+ const arg = args[i];
37
+ if (arg?.startsWith("-")) {
38
+ break;
39
+ }
40
+ if (arg) {
41
+ commands.push(arg);
42
+ }
43
+ }
44
+
45
+ return {
46
+ commands,
47
+ remaining: args.slice(i),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Convert option schema to parseArgs config
53
+ */
54
+ export function schemaToParseArgsOptions(schema: OptionSchema): {
55
+ options: ParseArgsConfig["options"];
56
+ } {
57
+ const options: NonNullable<ParseArgsConfig["options"]> = {};
58
+
59
+ for (const [name, def] of Object.entries(schema)) {
60
+ const parseArgsType = def.type === "boolean" ? "boolean" : "string";
61
+
62
+ const opt: NonNullable<ParseArgsConfig["options"]>[string] = {
63
+ type: parseArgsType,
64
+ multiple: def.type === "array",
65
+ };
66
+
67
+ // Only include short if alias is defined (parseArgs doesn't like undefined)
68
+ if (def.alias) {
69
+ opt.short = def.alias;
70
+ }
71
+
72
+ // Only include default if defined
73
+ // For non-boolean types, parseArgs expects string defaults
74
+ if (def.default !== undefined) {
75
+ if (parseArgsType === "string" && typeof def.default !== "string") {
76
+ opt.default = String(def.default);
77
+ } else {
78
+ opt.default = def.default as string | boolean | string[] | boolean[];
79
+ }
80
+ }
81
+
82
+ options[name] = opt;
83
+ }
84
+
85
+ return { options };
86
+ }
87
+
88
+ /**
89
+ * Parse and coerce option values
90
+ */
91
+ export function parseOptionValues<T extends OptionSchema>(
92
+ schema: T,
93
+ values: Record<string, unknown>
94
+ ): OptionValues<T> {
95
+ const result: Record<string, unknown> = {};
96
+
97
+ for (const [name, def] of Object.entries(schema)) {
98
+ let value = values[name];
99
+
100
+ // Check environment variable
101
+ if (value === undefined && def.env) {
102
+ value = process.env[def.env];
103
+ }
104
+
105
+ // Apply default
106
+ if (value === undefined && def.default !== undefined) {
107
+ value = def.default;
108
+ }
109
+
110
+ // Coerce type
111
+ if (value !== undefined) {
112
+ if (def.type === "number" && typeof value === "string") {
113
+ value = Number(value);
114
+ } else if (def.type === "boolean" && typeof value === "string") {
115
+ value = value === "true" || value === "1";
116
+ }
117
+
118
+ // Validate enum
119
+ if (def.enum && !def.enum.includes(String(value))) {
120
+ throw new Error(`Invalid value "${value}" for option "${name}". Must be one of: ${def.enum.join(", ")}`);
121
+ }
122
+ }
123
+
124
+ result[name] = value;
125
+ }
126
+
127
+ return result as OptionValues<T>;
128
+ }
129
+
130
+ /**
131
+ * Validate option values
132
+ */
133
+ export function validateOptions<T extends OptionSchema>(
134
+ schema: T,
135
+ values: OptionValues<T>
136
+ ): ParseError[] {
137
+ const errors: ParseError[] = [];
138
+
139
+ for (const [name, def] of Object.entries(schema)) {
140
+ const value = values[name as keyof typeof values];
141
+
142
+ if (def.required && value === undefined) {
143
+ errors.push({
144
+ type: "missing_required",
145
+ message: `Missing required option: ${name}`,
146
+ field: name,
147
+ });
148
+ }
149
+
150
+ if (def.type === "number" && typeof value === "number") {
151
+ if (def.min !== undefined && value < def.min) {
152
+ errors.push({
153
+ type: "validation",
154
+ message: `Option "${name}" must be at least ${def.min}`,
155
+ field: name,
156
+ });
157
+ }
158
+ if (def.max !== undefined && value > def.max) {
159
+ errors.push({
160
+ type: "validation",
161
+ message: `Option "${name}" must be at most ${def.max}`,
162
+ field: name,
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ return errors;
169
+ }
170
+
171
+ interface ParseCliArgsOptions<T extends OptionSchema> {
172
+ args: string[];
173
+ commands: Record<string, Command<T>>;
174
+ defaultCommand?: string;
175
+ }
176
+
177
+ /**
178
+ * Parse CLI arguments into a result
179
+ */
180
+ export function parseCliArgs<T extends OptionSchema>(
181
+ options: ParseCliArgsOptions<T>
182
+ ): ParseResult<T> {
183
+ const { args, commands, defaultCommand } = options;
184
+ const { commands: commandChain, remaining } = extractCommandChain(args);
185
+
186
+ // Check for help flag
187
+ const showHelp = remaining.includes("--help") || remaining.includes("-h");
188
+
189
+ // Find command
190
+ const commandName = commandChain[0] ?? defaultCommand;
191
+ if (!commandName) {
192
+ return {
193
+ command: null,
194
+ commandPath: [],
195
+ options: {} as OptionValues<T>,
196
+ args: remaining,
197
+ showHelp,
198
+ };
199
+ }
200
+
201
+ const command = commands[commandName];
202
+ if (!command) {
203
+ return {
204
+ command: null,
205
+ commandPath: commandChain,
206
+ options: {} as OptionValues<T>,
207
+ args: remaining,
208
+ showHelp,
209
+ error: {
210
+ type: "unknown_command",
211
+ message: `Unknown command: ${commandName}`,
212
+ },
213
+ };
214
+ }
215
+
216
+ // Parse options
217
+ const schema = command.options ?? ({} as T);
218
+ const parseArgsConfig = schemaToParseArgsOptions(schema);
219
+
220
+ let parsedValues: Record<string, unknown> = {};
221
+ try {
222
+ const { values } = parseArgs({
223
+ args: remaining,
224
+ ...parseArgsConfig,
225
+ allowPositionals: false,
226
+ });
227
+ parsedValues = values;
228
+ } catch {
229
+ // Ignore parse errors for now
230
+ }
231
+
232
+ const optionValues = parseOptionValues(schema, parsedValues);
233
+
234
+ return {
235
+ command,
236
+ commandPath: commandChain,
237
+ options: optionValues,
238
+ args: remaining,
239
+ showHelp,
240
+ };
241
+ }
@@ -0,0 +1,50 @@
1
+ import { defineCommand, type Command } from "../types/command.ts";
2
+ import { generateHelp } from "../cli/help.ts";
3
+
4
+ interface HelpCommandOptions {
5
+ getCommands: () => Command[];
6
+ appName?: string;
7
+ version?: string;
8
+ }
9
+
10
+ /**
11
+ * Create a help command
12
+ */
13
+ export function createHelpCommand(options: HelpCommandOptions) {
14
+ const { getCommands, appName = "cli", version } = options;
15
+
16
+ return defineCommand({
17
+ name: "help",
18
+ description: "Show help information",
19
+ aliases: ["--help", "-h"],
20
+ hidden: true,
21
+ options: {
22
+ command: {
23
+ type: "string" as const,
24
+ description: "Command to show help for",
25
+ },
26
+ },
27
+ execute: (ctx) => {
28
+ const commands = getCommands();
29
+ const commandName = ctx.options["command"];
30
+
31
+ if (commandName && typeof commandName === "string") {
32
+ const cmd = commands.find((c) => c.name === commandName);
33
+ if (cmd) {
34
+ console.log(generateHelp(cmd, { appName, version }));
35
+ return;
36
+ }
37
+ }
38
+
39
+ // Show root help
40
+ const rootCommand = defineCommand({
41
+ name: appName,
42
+ description: `${appName} CLI`,
43
+ subcommands: Object.fromEntries(commands.map((c) => [c.name, c])),
44
+ execute: () => {},
45
+ });
46
+
47
+ console.log(generateHelp(rootCommand, { appName, version }));
48
+ },
49
+ });
50
+ }
@@ -0,0 +1 @@
1
+ export * from "./help.ts";
@@ -0,0 +1,147 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Box component props
5
+ */
6
+ export interface BoxProps {
7
+ children?: React.ReactNode;
8
+ flexDirection?: "row" | "column";
9
+ padding?: number;
10
+ margin?: number;
11
+ borderStyle?: "single" | "double" | "round" | "none";
12
+ }
13
+
14
+ /**
15
+ * Box component for layout
16
+ */
17
+ export function Box({ children }: BoxProps) {
18
+ return React.createElement("div", null, children);
19
+ }
20
+
21
+ /**
22
+ * Text component props
23
+ */
24
+ export interface TextProps {
25
+ children?: React.ReactNode;
26
+ color?: string;
27
+ bold?: boolean;
28
+ dim?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Text component for styled text
33
+ */
34
+ export function Text({ children }: TextProps) {
35
+ return React.createElement("span", null, children);
36
+ }
37
+
38
+ /**
39
+ * Input component props
40
+ */
41
+ export interface InputProps {
42
+ value: string;
43
+ onChange: (value: string) => void;
44
+ placeholder?: string;
45
+ disabled?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Input component
50
+ */
51
+ export function Input({ value, onChange, placeholder }: InputProps) {
52
+ return React.createElement("input", {
53
+ value,
54
+ onChange: (e: { target: { value: string } }) => onChange(e.target.value),
55
+ placeholder,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Select option
61
+ */
62
+ export interface SelectOption {
63
+ label: string;
64
+ value: string;
65
+ }
66
+
67
+ /**
68
+ * Select component props
69
+ */
70
+ export interface SelectProps {
71
+ options: SelectOption[];
72
+ value?: string;
73
+ onChange: (value: string) => void;
74
+ }
75
+
76
+ /**
77
+ * Select component
78
+ */
79
+ export function Select({ options, value, onChange }: SelectProps) {
80
+ return React.createElement(
81
+ "select",
82
+ { value, onChange: (e: { target: { value: string } }) => onChange(e.target.value) },
83
+ options.map((opt) =>
84
+ React.createElement("option", { key: opt.value, value: opt.value }, opt.label)
85
+ )
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Button component props
91
+ */
92
+ export interface ButtonProps {
93
+ label: string;
94
+ onPress: () => void;
95
+ disabled?: boolean;
96
+ variant?: "primary" | "secondary" | "danger";
97
+ }
98
+
99
+ /**
100
+ * Button component
101
+ */
102
+ export function Button({ label, onPress, disabled }: ButtonProps) {
103
+ return React.createElement("button", { onClick: onPress, disabled }, label);
104
+ }
105
+
106
+ /**
107
+ * Modal component props
108
+ */
109
+ export interface ModalProps {
110
+ isOpen: boolean;
111
+ onClose: () => void;
112
+ title?: string;
113
+ children: React.ReactNode;
114
+ }
115
+
116
+ /**
117
+ * Modal component
118
+ */
119
+ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
120
+ if (!isOpen) return null;
121
+
122
+ return React.createElement(
123
+ "div",
124
+ { className: "modal" },
125
+ React.createElement(
126
+ "div",
127
+ { className: "modal-content" },
128
+ title && React.createElement("h2", null, title),
129
+ children,
130
+ React.createElement("button", { onClick: onClose }, "Close")
131
+ )
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Spinner component props
137
+ */
138
+ export interface SpinnerProps {
139
+ label?: string;
140
+ }
141
+
142
+ /**
143
+ * Spinner component
144
+ */
145
+ export function Spinner({ label }: SpinnerProps) {
146
+ return React.createElement("span", null, label ?? "Loading...");
147
+ }