@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,388 @@
1
+ # Guide 7: Async Commands with Cancellation (Complex)
2
+
3
+ Build commands that support cancellation with proper cleanup for long-running operations.
4
+
5
+ ## What You'll Build
6
+
7
+ A download manager that:
8
+ - Downloads files asynchronously
9
+ - Shows progress updates
10
+ - Supports cancellation (Ctrl+C in CLI, Esc in TUI)
11
+ - Cleans up partial downloads when cancelled
12
+
13
+ ```bash
14
+ download https://example.com/large-file.zip --output ./downloads/
15
+ ```
16
+
17
+ ## Step 1: Define the Command
18
+
19
+ Create `src/commands/download.ts`:
20
+
21
+ ```typescript
22
+ import path from "node:path";
23
+ import {
24
+ Command,
25
+ ConfigValidationError,
26
+ type AppContext,
27
+ type OptionSchema,
28
+ type OptionValues,
29
+ type CommandResult,
30
+ type CommandExecutionContext
31
+ } from "@pablozaiden/terminatui";
32
+
33
+ const options = {
34
+ url: {
35
+ type: "string",
36
+ description: "URL to download",
37
+ required: true,
38
+ label: "Download URL",
39
+ },
40
+ output: {
41
+ type: "string",
42
+ description: "Output directory",
43
+ default: "./downloads",
44
+ label: "Output Directory",
45
+ },
46
+ "chunk-size": {
47
+ type: "string",
48
+ description: "Download chunk size in KB",
49
+ default: "1024",
50
+ label: "Chunk Size (KB)",
51
+ },
52
+ } satisfies OptionSchema;
53
+
54
+ interface DownloadConfig {
55
+ url: URL;
56
+ outputDir: string;
57
+ fileName: string;
58
+ chunkSize: number;
59
+ }
60
+ ```
61
+
62
+ ## Step 2: Implement buildConfig
63
+
64
+ ```typescript
65
+ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
66
+ readonly name = "download";
67
+ readonly description = "Download a file from URL";
68
+ readonly options = options;
69
+ readonly displayName = "File Downloader";
70
+ readonly actionLabel = "Download";
71
+
72
+ override buildConfig(
73
+ _ctx: AppContext,
74
+ opts: OptionValues<typeof options>
75
+ ): DownloadConfig {
76
+ // Validate URL
77
+ const urlStr = opts["url"] as string;
78
+ if (!urlStr) {
79
+ throw new ConfigValidationError("URL is required", "url");
80
+ }
81
+
82
+ let url: URL;
83
+ try {
84
+ url = new URL(urlStr);
85
+ } catch {
86
+ throw new ConfigValidationError("Invalid URL format", "url");
87
+ }
88
+
89
+ // Validate output directory
90
+ const outputDir = path.resolve(opts["output"] as string ?? "./downloads");
91
+
92
+ // Extract filename from URL
93
+ const fileName = path.basename(url.pathname) || "download";
94
+
95
+ // Parse chunk size
96
+ const chunkSizeStr = opts["chunk-size"] as string ?? "1024";
97
+ const chunkSize = parseInt(chunkSizeStr, 10) * 1024; // Convert KB to bytes
98
+ if (isNaN(chunkSize) || chunkSize <= 0) {
99
+ throw new ConfigValidationError("Chunk size must be a positive number", "chunk-size");
100
+ }
101
+
102
+ return { url, outputDir, fileName, chunkSize };
103
+ }
104
+ ```
105
+
106
+ ## Step 3: Implement Cancellable Download
107
+
108
+ ```typescript
109
+ async execute(
110
+ ctx: AppContext,
111
+ config: DownloadConfig,
112
+ execCtx?: CommandExecutionContext
113
+ ): Promise<CommandResult> {
114
+ const { url, outputDir, fileName, chunkSize } = config;
115
+ const outputPath = path.join(outputDir, fileName);
116
+ const signal = execCtx?.signal;
117
+
118
+ ctx.logger.info(`Starting download: ${url}`);
119
+ ctx.logger.info(`Output: ${outputPath}`);
120
+
121
+ // Create output directory
122
+ await Bun.write(path.join(outputDir, ".keep"), "");
123
+
124
+ // Track download state for cleanup
125
+ let downloadedBytes = 0;
126
+ let totalBytes = 0;
127
+ let partialFile: Bun.FileSink | null = null;
128
+
129
+ try {
130
+ // Check for cancellation before starting
131
+ if (signal?.aborted) {
132
+ return { success: false, message: "Download cancelled before start" };
133
+ }
134
+
135
+ // Fetch with AbortSignal
136
+ const response = await fetch(url.toString(), { signal });
137
+
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
140
+ }
141
+
142
+ totalBytes = parseInt(response.headers.get("content-length") ?? "0", 10);
143
+ const reader = response.body?.getReader();
144
+
145
+ if (!reader) {
146
+ throw new Error("No response body");
147
+ }
148
+
149
+ // Open file for writing
150
+ partialFile = Bun.file(outputPath).writer();
151
+
152
+ console.log(`Downloading ${fileName}...`);
153
+ if (totalBytes > 0) {
154
+ console.log(`Total size: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`);
155
+ }
156
+
157
+ // Read chunks with cancellation checks
158
+ while (true) {
159
+ // Check for cancellation between chunks
160
+ if (signal?.aborted) {
161
+ ctx.logger.warn("Download cancelled by user");
162
+ throw new Error("AbortError");
163
+ }
164
+
165
+ const { done, value } = await reader.read();
166
+
167
+ if (done) {
168
+ break;
169
+ }
170
+
171
+ // Write chunk
172
+ partialFile.write(value);
173
+ downloadedBytes += value.byteLength;
174
+
175
+ // Log progress
176
+ if (totalBytes > 0) {
177
+ const percent = ((downloadedBytes / totalBytes) * 100).toFixed(1);
178
+ const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
179
+ const mbTotal = (totalBytes / 1024 / 1024).toFixed(2);
180
+ process.stdout.write(`\rProgress: ${percent}% (${mbDownloaded}/${mbTotal} MB)`);
181
+ } else {
182
+ const mbDownloaded = (downloadedBytes / 1024 / 1024).toFixed(2);
183
+ process.stdout.write(`\rDownloaded: ${mbDownloaded} MB`);
184
+ }
185
+ }
186
+
187
+ // Finalize file
188
+ await partialFile.end();
189
+ console.log("\nDownload complete!");
190
+
191
+ return {
192
+ success: true,
193
+ data: {
194
+ file: outputPath,
195
+ size: downloadedBytes,
196
+ url: url.toString(),
197
+ },
198
+ message: `Downloaded ${fileName} (${(downloadedBytes / 1024 / 1024).toFixed(2)} MB)`,
199
+ };
200
+
201
+ } catch (error) {
202
+ // Handle cancellation
203
+ if (signal?.aborted || (error as Error).name === "AbortError") {
204
+ console.log("\nDownload cancelled.");
205
+
206
+ // Cleanup partial file
207
+ await this.cleanup(outputPath, partialFile);
208
+
209
+ return {
210
+ success: false,
211
+ message: "Download cancelled by user",
212
+ data: {
213
+ downloadedBytes,
214
+ cancelled: true,
215
+ },
216
+ };
217
+ }
218
+
219
+ // Handle other errors
220
+ await this.cleanup(outputPath, partialFile);
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ private async cleanup(
226
+ outputPath: string,
227
+ sink: Bun.FileSink | null
228
+ ): Promise<void> {
229
+ try {
230
+ // Close file handle
231
+ if (sink) {
232
+ await sink.end();
233
+ }
234
+
235
+ // Remove partial file
236
+ const file = Bun.file(outputPath);
237
+ if (await file.exists()) {
238
+ await Bun.write(outputPath, ""); // Clear file
239
+ // In production: fs.unlinkSync(outputPath);
240
+ console.log("Cleaned up partial download.");
241
+ }
242
+ } catch (e) {
243
+ // Ignore cleanup errors
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ ## Step 4: Add Result Rendering
250
+
251
+ ```typescript
252
+ import React from "react";
253
+ import { Text, Box } from "ink";
254
+
255
+ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
256
+ // ... previous code ...
257
+
258
+ /**
259
+ * Custom TUI result display
260
+ */
261
+ override renderResult(result: CommandResult): React.ReactNode {
262
+ if (!result.success && result.data?.cancelled) {
263
+ return (
264
+ <Box flexDirection="column">
265
+ <Text color="yellow">⚠ Download Cancelled</Text>
266
+ {result.data?.downloadedBytes > 0 && (
267
+ <Text dimColor>
268
+ Partial: {(result.data.downloadedBytes / 1024 / 1024).toFixed(2)} MB
269
+ </Text>
270
+ )}
271
+ </Box>
272
+ );
273
+ }
274
+
275
+ if (!result.success) {
276
+ return <Text color="red">✗ {result.message}</Text>;
277
+ }
278
+
279
+ return (
280
+ <Box flexDirection="column">
281
+ <Text color="green">✓ Download Complete</Text>
282
+ <Text>File: {result.data?.file}</Text>
283
+ <Text>Size: {(result.data?.size / 1024 / 1024).toFixed(2)} MB</Text>
284
+ </Box>
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Copy file path to clipboard
290
+ */
291
+ override getClipboardContent(result: CommandResult): string | undefined {
292
+ if (result.success && result.data?.file) {
293
+ return result.data.file;
294
+ }
295
+ return undefined;
296
+ }
297
+ }
298
+ ```
299
+
300
+ ## Step 5: Create the Application
301
+
302
+ Create `src/index.ts`:
303
+
304
+ ```typescript
305
+ import { TuiApplication } from "@pablozaiden/terminatui";
306
+ import { DownloadCommand } from "./commands/download";
307
+
308
+ class DownloadManager extends TuiApplication {
309
+ constructor() {
310
+ super({
311
+ name: "download-manager",
312
+ version: "1.0.0",
313
+ commands: [new DownloadCommand()],
314
+ });
315
+ }
316
+ }
317
+
318
+ await new DownloadManager().run();
319
+ ```
320
+
321
+ ## Step 6: Test Cancellation
322
+
323
+ ```bash
324
+ # Start a large download
325
+ bun src/index.ts download https://speed.hetzner.de/100MB.bin --output ./test-downloads
326
+
327
+ # While downloading, press Ctrl+C
328
+ # Should see: "Download cancelled. Cleaned up partial download."
329
+
330
+ # Run TUI mode
331
+ bun src/index.ts --tui
332
+
333
+ # Start download, then press Esc to cancel
334
+ # Same cancellation behavior with cleanup
335
+ ```
336
+
337
+ ## Cancellation Patterns
338
+
339
+ ### 1. Check Signal Before Long Operations
340
+
341
+ ```typescript
342
+ if (signal?.aborted) {
343
+ return { success: false, message: "Cancelled" };
344
+ }
345
+ ```
346
+
347
+ ### 2. Pass Signal to fetch/APIs
348
+
349
+ ```typescript
350
+ await fetch(url, { signal });
351
+ await someAsyncApi({ signal });
352
+ ```
353
+
354
+ ### 3. Check Between Iterations
355
+
356
+ ```typescript
357
+ for (const item of items) {
358
+ if (signal?.aborted) break;
359
+ await processItem(item);
360
+ }
361
+ ```
362
+
363
+ ### 4. Always Cleanup
364
+
365
+ ```typescript
366
+ try {
367
+ // ... cancellable work ...
368
+ } catch (error) {
369
+ if (signal?.aborted) {
370
+ await cleanup();
371
+ return { success: false, message: "Cancelled" };
372
+ }
373
+ throw error;
374
+ }
375
+ ```
376
+
377
+ ## What You Learned
378
+
379
+ - Accept `CommandExecutionContext` with `AbortSignal`
380
+ - Check `signal.aborted` between operations
381
+ - Pass signal to `fetch` and other async APIs
382
+ - Clean up resources on cancellation
383
+ - Return meaningful results for cancelled operations
384
+ - Render different UI for cancelled vs success vs error
385
+
386
+ ## Next Steps
387
+
388
+ → [Guide 8: Building a Complete Application](08-complete-application.md)