@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
package/README.md CHANGED
@@ -12,7 +12,7 @@ A type-safe, class-based framework for building CLI and TUI applications in Type
12
12
  - **Nested subcommands** - Hierarchical command structures with path resolution
13
13
  - **Lifecycle hooks** - `beforeExecute()` and `afterExecute()` hooks on commands
14
14
  - **Service container** - `AppContext` provides dependency injection for services
15
- - **Integrated logging** - Logger with TUI-aware output handling
15
+ - **Integrated logging** - Logger with TUI-aware output handling (live log modal with global copy shortcut)
16
16
  - **Cancellation support** - AbortSignal-based cancellation for long-running commands
17
17
  - **Config validation** - `buildConfig()` hook for transforming and validating options
18
18
 
@@ -27,7 +27,12 @@ bun add @pablozaiden/terminatui
27
27
  ### 1. Define a Command
28
28
 
29
29
  ```typescript
30
- import { Command, type AppContext, type OptionSchema, type CommandResult } from "@pablozaiden/terminatui";
30
+ import {
31
+ Command,
32
+ type OptionSchema,
33
+ type CommandResult,
34
+ type CommandExecutionContext,
35
+ } from "@pablozaiden/terminatui";
31
36
 
32
37
  const greetOptions = {
33
38
  name: {
@@ -48,7 +53,10 @@ class GreetCommand extends Command<typeof greetOptions> {
48
53
  readonly description = "Greet someone";
49
54
  readonly options = greetOptions;
50
55
 
51
- override execute(ctx: AppContext, config: { name: string; loud: boolean }): CommandResult {
56
+ override execute(
57
+ config: { name: string; loud: boolean },
58
+ _execCtx: CommandExecutionContext
59
+ ): CommandResult {
52
60
  const message = `Hello, ${config.name}!`;
53
61
  console.log(config.loud ? message.toUpperCase() : message);
54
62
  return { success: true, message };
@@ -77,7 +85,11 @@ class MyApp extends Application {
77
85
 
78
86
  ```typescript
79
87
  // index.ts
88
+ // Recommended: let Terminatui read `Bun.argv.slice(2)`
80
89
  await new MyApp().run();
90
+
91
+ // For tests or programmatic invocation:
92
+ // await new MyApp().runFromArgs(["greet", "--name", "World"]);
81
93
  ```
82
94
 
83
95
  ```bash
@@ -121,9 +133,8 @@ abstract class Command<TOptions extends OptionSchema = OptionSchema, TConfig = u
121
133
 
122
134
  // Required: Main execution method
123
135
  abstract execute(
124
- ctx: AppContext,
125
136
  config: TConfig,
126
- execCtx?: CommandExecutionContext
137
+ execCtx: CommandExecutionContext
127
138
  ): Promise<CommandResult | void> | CommandResult | void;
128
139
 
129
140
  // Optional: Transform/validate options before execute
@@ -170,10 +181,15 @@ The `Application` class manages command registration and execution:
170
181
  ```typescript
171
182
  class Application {
172
183
  constructor(config: ApplicationConfig);
173
-
174
- run(args?: string[]): Promise<void>;
184
+
185
+ // Recommended entrypoint (reads `Bun.argv.slice(2)`)
186
+ run(): Promise<void>;
187
+
188
+ // Useful for tests or programmatic invocation
189
+ runFromArgs(argv: string[]): Promise<void>;
190
+
175
191
  getContext(): AppContext;
176
-
192
+
177
193
  // Lifecycle hooks (override in subclass)
178
194
  onBeforeRun?(command: Command, options: Record<string, unknown>): void;
179
195
  onAfterRun?(command: Command, result: unknown): void;
@@ -196,6 +212,8 @@ Access application-wide services and configuration:
196
212
 
197
213
  ```typescript
198
214
  import { AppContext } from "@pablozaiden/terminatui";
215
+ import type { CommandExecutionContext } from "@pablozaiden/terminatui";
216
+ import { AbortError } from "@pablozaiden/terminatui";
199
217
 
200
218
  // Get the current context (set during Application.run())
201
219
  const ctx = AppContext.current;
@@ -234,7 +252,7 @@ interface OptionDef {
234
252
  tuiHidden?: boolean; // Hide from TUI form
235
253
  }
236
254
 
237
- type OptionSchema = Record<string, OptionDef>;
255
+ type OptionSchema = Record<string, OptionDef>; // See library types
238
256
  ```
239
257
 
240
258
  ## Config Validation with buildConfig
@@ -242,7 +260,7 @@ type OptionSchema = Record<string, OptionDef>;
242
260
  Use `buildConfig()` to transform and validate options before execution:
243
261
 
244
262
  ```typescript
245
- import { Command, ConfigValidationError, type AppContext, type OptionValues } from "@pablozaiden/terminatui";
263
+ import { Command, ConfigValidationError, type OptionValues } from "@pablozaiden/terminatui";
246
264
 
247
265
  interface MyConfig {
248
266
  resolvedPath: string;
@@ -254,7 +272,7 @@ class MyCommand extends Command<typeof myOptions, MyConfig> {
254
272
  readonly description = "Do something";
255
273
  readonly options = myOptions;
256
274
 
257
- override buildConfig(ctx: AppContext, opts: OptionValues<typeof myOptions>): MyConfig {
275
+ override buildConfig(opts: OptionValues<typeof myOptions>): MyConfig {
258
276
  const pathRaw = opts["path"] as string | undefined;
259
277
  if (!pathRaw) {
260
278
  throw new ConfigValidationError("Missing required option: path", "path");
@@ -271,9 +289,16 @@ class MyCommand extends Command<typeof myOptions, MyConfig> {
271
289
  };
272
290
  }
273
291
 
274
- override async execute(ctx: AppContext, config: MyConfig): Promise<CommandResult> {
292
+ override async execute(
293
+ config: MyConfig,
294
+ execCtx: CommandExecutionContext
295
+ ): Promise<CommandResult> {
275
296
  // config is now typed as MyConfig
276
- ctx.logger.info(`Processing ${config.count} items from ${config.resolvedPath}`);
297
+ if (execCtx.signal.aborted) {
298
+ throw new AbortError("Command was cancelled");
299
+ }
300
+
301
+ AppContext.current.logger.info(`Processing ${config.count} items from ${config.resolvedPath}`);
277
302
  return { success: true };
278
303
  }
279
304
  }
@@ -288,19 +313,18 @@ class LongRunningCommand extends Command<typeof options> {
288
313
  // ...
289
314
 
290
315
  override async execute(
291
- ctx: AppContext,
292
316
  config: Config,
293
- execCtx?: CommandExecutionContext
317
+ execCtx: CommandExecutionContext
294
318
  ): Promise<CommandResult> {
295
319
  for (const item of items) {
296
320
  // Check for cancellation
297
- if (execCtx?.signal.aborted) {
321
+ if (execCtx.signal.aborted) {
298
322
  throw new AbortError("Command was cancelled");
299
323
  }
300
-
301
- await processItem(item, execCtx?.signal);
324
+
325
+ await processItem(item, execCtx.signal);
302
326
  }
303
-
327
+
304
328
  return { success: true };
305
329
  }
306
330
  }
@@ -350,6 +374,9 @@ Extend `TuiApplication` instead of `Application` to get automatic TUI support:
350
374
  import { TuiApplication, Command } from "@pablozaiden/terminatui";
351
375
 
352
376
  class MyApp extends TuiApplication {
377
+ // Each app decides what "default" means.
378
+ protected override defaultRenderer = "opentui" as const;
379
+
353
380
  constructor() {
354
381
  super({
355
382
  name: "myapp",
@@ -362,11 +389,13 @@ class MyApp extends TuiApplication {
362
389
  }
363
390
  ```
364
391
 
365
- When run with no arguments, the app launches an interactive TUI instead of showing help:
392
+ Execution mode is controlled only by the selected mode (`--mode`) or the app’s configured default mode.
366
393
 
367
394
  ```bash
368
- myapp # Launches TUI
369
- myapp run --verbose # Runs in CLI mode
395
+ myapp # Uses app default mode
396
+ myapp --mode opentui # Forces TUI (OpenTUI)
397
+ myapp --mode ink # Forces TUI (Ink)
398
+ myapp --mode cli run --verbose # Forces CLI
370
399
  ```
371
400
 
372
401
  ### TUI Metadata
@@ -412,14 +441,15 @@ class RunCommand extends Command<typeof runOptions, RunConfig> {
412
441
  override readonly immediateExecution = false; // Run immediately on selection
413
442
 
414
443
  // Return structured results for display
415
- override async execute(ctx: AppContext, config: RunConfig): Promise<CommandResult> {
416
- const result = await runTask(config);
417
- return {
418
- success: true,
419
- data: result,
420
- message: "Task completed"
421
- };
422
- }
444
+ override async execute(config: RunConfig, _execCtx: CommandExecutionContext): Promise<CommandResult> {
445
+ const result = await runTask(config);
446
+ return {
447
+ success: true,
448
+ data: result,
449
+ message: "Task completed",
450
+ };
451
+ }
452
+
423
453
 
424
454
  // Custom result rendering (React/TSX)
425
455
  override renderResult(result: CommandResult): ReactNode {
@@ -452,7 +482,7 @@ The built-in TUI provides:
452
482
  - **Command Selector** - Navigate and select commands with arrow keys
453
483
  - **Config Form** - Auto-generated forms from option schemas with field groups
454
484
  - **Field Editor** - Edit field values (text, number, boolean, enum)
455
- - **CLI Modal** - View equivalent CLI command (press `C`)
485
+ - **CLI Args Button** - View equivalent CLI command from the config form
456
486
  - **Results Panel** - Display command results with custom rendering
457
487
  - **Logs Panel** - View application logs in real-time
458
488
  - **Clipboard Support** - Centralized copy with Ctrl+Y
@@ -464,9 +494,8 @@ The built-in TUI provides:
464
494
  | Key | Action |
465
495
  |-----|--------|
466
496
  | ↑/↓ | Navigate fields/commands |
467
- | Enter | Edit field / Execute command |
497
+ | Enter | Edit field / Execute command / Press button |
468
498
  | Tab | Cycle focus between panels |
469
- | C | Show CLI command modal |
470
499
  | L | Toggle logs panel |
471
500
  | Ctrl+Y | Copy current content to clipboard |
472
501
  | Esc | Back / Cancel running command |
@@ -486,7 +515,7 @@ import {
486
515
  useKeyboardHandler, // Register keyboard handlers with priority
487
516
  useClipboard, // Clipboard operations with OSC 52
488
517
  useLogStream, // Stream logs from logger
489
- useSpinner, // Animated spinner
518
+ // (renderer-specific) Spinner UI lives in adapters
490
519
  useCommandExecutor, // Execute commands with cancellation
491
520
 
492
521
  // Context
@@ -494,7 +523,6 @@ import {
494
523
  KeyboardPriority, // Global < Focused < Modal
495
524
 
496
525
  // Components
497
- Theme, // TUI color theme
498
526
  JsonHighlight, // Syntax-highlighted JSON display
499
527
  } from "@pablozaiden/terminatui";
500
528
  ```
@@ -504,19 +532,12 @@ import {
504
532
  Terminatui includes utilities for formatted CLI output:
505
533
 
506
534
  ```typescript
507
- import { colors, table, bulletList, keyValueList } from "@pablozaiden/terminatui";
535
+ import { colors } from "@pablozaiden/terminatui";
508
536
 
509
537
  // Colors
510
538
  console.log(colors.red("Error!"));
511
539
  console.log(colors.success("Done!")); // ✓ Done!
512
540
  console.log(colors.bold(colors.blue("Title")));
513
-
514
- // Tables
515
- console.log(table(data, ["name", "value", "status"]));
516
-
517
- // Lists
518
- console.log(bulletList(["Item 1", "Item 2", "Item 3"]));
519
- console.log(keyValueList({ name: "Test", count: 42 }));
520
541
  ```
521
542
 
522
543
  ## License
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@pablozaiden/terminatui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal UI and Command Line Application Framework",
5
5
  "repository": {
6
6
  "url": "https://github.com/PabloZaiden/terminatui"
7
7
  },
8
8
  "type": "module",
9
- "main": "src/index.ts",
10
- "types": "src/index.ts",
9
+ "files": [
10
+ "src/**/*"
11
+ ],
11
12
  "exports": {
12
- ".": "./src/index.ts",
13
- "./cli": "./src/cli/index.ts",
14
- "./tui": "./src/tui/index.ts",
15
- "./components": "./src/components/index.ts",
16
- "./hooks": "./src/hooks/index.ts"
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "default": "./src/index.ts"
16
+ }
17
17
  },
18
18
  "scripts": {
19
19
  "build": "bunx tsc --noEmit",
@@ -22,6 +22,9 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@opentui/react": "0.1.68",
25
+ "ink": "^6.6.0",
26
+ "ink-select-input": "^6.2.0",
27
+ "ink-text-input": "^6.0.0",
25
28
  "tslog": "^4.9.3"
26
29
  },
27
30
  "devDependencies": {
@@ -1,8 +1,10 @@
1
- import { describe, test, expect, afterEach } from "bun:test";
1
+ import { describe, test, expect } from "bun:test";
2
2
  import { Application } from "../core/application.ts";
3
3
  import { Command } from "../core/command.ts";
4
- import { AppContext } from "../core/context.ts";
5
4
  import type { OptionSchema, OptionValues, OptionDef } from "../types/command.ts";
5
+ import { AppContext } from "../core/context.ts";
6
+ import { LogLevel } from "../core/logger.ts";
7
+ import { KNOWN_COMMANDS } from "../core/knownCommands.ts";
6
8
 
7
9
  // Define a proper option schema
8
10
  const testOptions = {
@@ -21,7 +23,6 @@ class TestCommand extends Command<typeof testOptions> {
21
23
  executedWith: Record<string, unknown> | null = null;
22
24
 
23
25
  override async execute(
24
- _ctx: AppContext,
25
26
  opts: OptionValues<typeof testOptions>
26
27
  ): Promise<void> {
27
28
  this.executedWith = opts as Record<string, unknown>;
@@ -35,17 +36,57 @@ class TuiCommand extends Command<OptionSchema> {
35
36
 
36
37
  executed = false;
37
38
 
38
- override async execute(_ctx: AppContext): Promise<void> {
39
+ override async execute(): Promise<void> {
39
40
  this.executed = true;
40
41
  }
41
42
  }
42
43
 
43
44
  describe("Application", () => {
44
- afterEach(() => {
45
- AppContext.clearCurrent();
46
- });
47
-
48
45
  describe("constructor", () => {
46
+ test("rejects reserved help command definitions", () => {
47
+ class ReservedCommand extends Command<OptionSchema> {
48
+ readonly name = KNOWN_COMMANDS.help;
49
+ readonly description = "tries to override built-in";
50
+ readonly options = {};
51
+
52
+ override async execute(): Promise<void> {}
53
+ }
54
+
55
+ expect(() => {
56
+ new Application({
57
+ name: "test-app",
58
+ version: "1.0.0",
59
+ commands: [new ReservedCommand()],
60
+ });
61
+ }).toThrow(/reserved/i);
62
+
63
+ class SubCommand extends Command<OptionSchema> {
64
+ readonly name = KNOWN_COMMANDS.help;
65
+ readonly description = "user help";
66
+ readonly options = {};
67
+
68
+ override async execute(): Promise<void> {}
69
+ }
70
+
71
+ class ParentCommand extends Command<OptionSchema> {
72
+ readonly name = "parent";
73
+ readonly description = "parent";
74
+ readonly options = {};
75
+
76
+ override subCommands = [new SubCommand()];
77
+
78
+ override async execute(): Promise<void> {}
79
+ }
80
+
81
+ expect(() => {
82
+ new Application({
83
+ name: "test-app",
84
+ version: "1.0.0",
85
+ commands: [new ParentCommand()],
86
+ });
87
+ }).toThrow(/automatically injected/i);
88
+ });
89
+
49
90
  test("creates application with name and version", () => {
50
91
  const app = new Application({
51
92
  name: "test-app",
@@ -56,14 +97,16 @@ describe("Application", () => {
56
97
  expect(app.version).toBe("1.0.0");
57
98
  });
58
99
 
59
- test("creates context and sets as current", () => {
60
- const app = new Application({
100
+ test("creates context as side effect of creating application", () => {
101
+ // side effect of creating an application is setting the current context
102
+ new Application({
61
103
  name: "test-app",
62
104
  version: "1.0.0",
63
105
  commands: [],
64
106
  });
65
- expect(AppContext.hasCurrent()).toBe(true);
66
- expect(app.context).toBe(AppContext.current);
107
+
108
+ expect(AppContext.current.config.name).toBe("test-app");
109
+ expect(AppContext.current.config.version).toBe("1.0.0");
67
110
  });
68
111
 
69
112
  test("registers provided commands", () => {
@@ -91,7 +134,7 @@ describe("Application", () => {
91
134
  version: "1.0.0",
92
135
  commands: [],
93
136
  });
94
- expect(app.registry.has("help")).toBe(true);
137
+ expect(app.registry.has(KNOWN_COMMANDS.help)).toBe(true);
95
138
  });
96
139
 
97
140
  test("injects help subcommand into commands", () => {
@@ -103,32 +146,11 @@ describe("Application", () => {
103
146
  commands: [cmd],
104
147
  });
105
148
  expect(cmd.subCommands).toBeDefined();
106
- expect(cmd.subCommands?.some((c) => c.name === "help")).toBe(true);
107
- });
108
- });
109
-
110
- describe("getContext", () => {
111
- test("returns the application context", () => {
112
- const app = new Application({
113
- name: "test-app",
114
- version: "1.0.0",
115
- commands: [],
116
- });
117
- expect(app.getContext()).toBe(app.context);
149
+ expect(cmd.subCommands?.some((c) => c.name === KNOWN_COMMANDS.help)).toBe(true);
118
150
  });
119
151
  });
120
152
 
121
153
  describe("run", () => {
122
- test("shows help when no args and no default command", async () => {
123
- const app = new Application({
124
- name: "test-app",
125
- version: "1.0.0",
126
- commands: [new TestCommand()],
127
- });
128
- // Should not throw
129
- await app.run([]);
130
- });
131
-
132
154
  test("runs default command when no args", async () => {
133
155
  const cmd = new TuiCommand();
134
156
  const app = new Application({
@@ -137,31 +159,30 @@ describe("Application", () => {
137
159
  commands: [cmd],
138
160
  defaultCommand: "tui-cmd",
139
161
  });
140
- await app.run([]);
162
+ await app.runFromArgs([]);
141
163
  expect(cmd.executed).toBe(true);
142
164
  });
143
165
 
144
- test("runs specified command", async () => {
166
+ test("runs specified command and passes options", async () => {
145
167
  const cmd = new TestCommand();
146
168
  const app = new Application({
147
169
  name: "test-app",
148
170
  version: "1.0.0",
149
171
  commands: [cmd],
150
172
  });
151
- await app.run(["test"]);
152
- expect(cmd.executedWith).not.toBeNull();
173
+
174
+ await app.runFromArgs(["test", "--value", "hello"]);
175
+ expect(cmd.executedWith?.["value"]).toBe("hello");
153
176
  });
154
177
 
155
- test("passes options to command", async () => {
156
- const cmd = new TestCommand();
178
+ test("with no args and no default, prints help (no throw)", async () => {
157
179
  const app = new Application({
158
180
  name: "test-app",
159
181
  version: "1.0.0",
160
- commands: [cmd],
182
+ commands: [new TestCommand()],
161
183
  });
162
- await app.run(["test", "--value", "hello"]);
163
- expect(cmd.executedWith).not.toBeNull();
164
- expect(cmd.executedWith?.["value"]).toBe("hello");
184
+
185
+ await app.runFromArgs([]);
165
186
  });
166
187
  });
167
188
 
@@ -179,7 +200,7 @@ describe("Application", () => {
179
200
  called = true;
180
201
  },
181
202
  });
182
- await app.run(["test"]);
203
+ await app.runFromArgs(["test"]);
183
204
  expect(called).toBe(true);
184
205
  });
185
206
 
@@ -196,7 +217,7 @@ describe("Application", () => {
196
217
  called = true;
197
218
  },
198
219
  });
199
- await app.run(["test"]);
220
+ await app.runFromArgs(["test"]);
200
221
  expect(called).toBe(true);
201
222
  });
202
223
 
@@ -219,11 +240,11 @@ describe("Application", () => {
219
240
  commands: [new ErrorCommand()],
220
241
  });
221
242
  app.setHooks({
222
- onError: async (_ctx, error) => {
243
+ onError: async (error) => {
223
244
  errorCaught = error;
224
245
  },
225
246
  });
226
- await app.run(["error-cmd"]);
247
+ await app.runFromArgs(["error-cmd"]);
227
248
  expect(errorCaught?.message).toBe("Test error");
228
249
  });
229
250
  });
@@ -250,7 +271,6 @@ describe("Application", () => {
250
271
  readonly options = configOptions;
251
272
 
252
273
  override buildConfig(
253
- _ctx: AppContext,
254
274
  opts: OptionValues<typeof configOptions>
255
275
  ): ParsedConfig {
256
276
  buildConfigCalled = true;
@@ -260,7 +280,7 @@ describe("Application", () => {
260
280
  };
261
281
  }
262
282
 
263
- override async execute(_ctx: AppContext, config: ParsedConfig): Promise<void> {
283
+ override async execute(config: ParsedConfig): Promise<void> {
264
284
  receivedConfig = config;
265
285
  }
266
286
  }
@@ -271,7 +291,7 @@ describe("Application", () => {
271
291
  commands: [new ConfigCommand()],
272
292
  });
273
293
 
274
- await app.run(["config-cmd", "--value", "test", "--count", "42"]);
294
+ await app.runFromArgs(["config-cmd", "--value", "test", "--count", "42"]);
275
295
 
276
296
  expect(buildConfigCalled).toBe(true);
277
297
  expect(receivedConfig).toEqual({ value: "test", count: 42 });
@@ -286,7 +306,6 @@ describe("Application", () => {
286
306
  readonly options = testOptions;
287
307
 
288
308
  override async execute(
289
- _ctx: AppContext,
290
309
  opts: OptionValues<typeof testOptions>
291
310
  ): Promise<void> {
292
311
  receivedOpts = opts as Record<string, unknown>;
@@ -299,7 +318,7 @@ describe("Application", () => {
299
318
  commands: [new NoConfigCommand()],
300
319
  });
301
320
 
302
- await app.run(["no-config-cmd", "--value", "hello"]);
321
+ await app.runFromArgs(["no-config-cmd", "--value", "hello"]);
303
322
 
304
323
  expect(receivedOpts).toEqual({ value: "hello" });
305
324
  });
@@ -328,12 +347,12 @@ describe("Application", () => {
328
347
  });
329
348
 
330
349
  app.setHooks({
331
- onError: async (_ctx, error) => {
350
+ onError: async (error) => {
332
351
  errorCaught = error;
333
352
  },
334
353
  });
335
354
 
336
- await app.run(["fail-config", "--value", "test"]);
355
+ await app.runFromArgs(["fail-config", "--value", "test"]);
337
356
 
338
357
  expect(errorCaught?.message).toBe("Config validation failed");
339
358
  });
@@ -349,7 +368,7 @@ describe("Application", () => {
349
368
  });
350
369
 
351
370
  // Should not throw - global option should be parsed and removed
352
- await app.run(["--log-level", "debug", "test", "--value", "hello"]);
371
+ await app.runFromArgs(["--log-level", "debug", "test", "--value", "hello"]);
353
372
  expect(cmd.executedWith?.["value"]).toBe("hello");
354
373
  });
355
374
 
@@ -361,7 +380,7 @@ describe("Application", () => {
361
380
  commands: [cmd],
362
381
  });
363
382
 
364
- await app.run(["test", "--log-level", "debug", "--value", "hello"]);
383
+ await app.runFromArgs(["test", "--log-level", "debug", "--value", "hello"]);
365
384
  expect(cmd.executedWith?.["value"]).toBe("hello");
366
385
  });
367
386
 
@@ -374,14 +393,14 @@ describe("Application", () => {
374
393
  });
375
394
 
376
395
  // All of these should work (case-insensitive)
377
- await app.run(["--log-level", "debug", "test"]);
378
- expect(app.context.logger.getMinLevel()).toBe(2); // Debug = 2
396
+ await app.runFromArgs(["--log-level", "debug", "test"]);
397
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
379
398
 
380
- await app.run(["--log-level", "Debug", "test"]);
381
- expect(app.context.logger.getMinLevel()).toBe(2);
399
+ await app.runFromArgs(["--log-level", "Debug", "test"]);
400
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
382
401
 
383
- await app.run(["--log-level", "DEBUG", "test"]);
384
- expect(app.context.logger.getMinLevel()).toBe(2);
402
+ await app.runFromArgs(["--log-level", "DEBUG", "test"]);
403
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.debug);
385
404
  });
386
405
 
387
406
  test("parses --detailed-logs flag", async () => {
@@ -392,7 +411,7 @@ describe("Application", () => {
392
411
  commands: [cmd],
393
412
  });
394
413
 
395
- await app.run(["--detailed-logs", "test"]);
414
+ await app.runFromArgs(["--detailed-logs", "test"]);
396
415
  // Should not throw - flag is recognized
397
416
  expect(cmd.executedWith).not.toBeNull();
398
417
  });
@@ -405,7 +424,7 @@ describe("Application", () => {
405
424
  commands: [cmd],
406
425
  });
407
426
 
408
- await app.run(["--no-detailed-logs", "test"]);
427
+ await app.runFromArgs(["--no-detailed-logs", "test"]);
409
428
  // Should not throw - flag is recognized
410
429
  expect(cmd.executedWith).not.toBeNull();
411
430
  });
@@ -418,8 +437,8 @@ describe("Application", () => {
418
437
  commands: [cmd],
419
438
  });
420
439
 
421
- await app.run(["--log-level=warn", "test"]);
422
- expect(app.context.logger.getMinLevel()).toBe(4); // Warn = 4
440
+ await app.runFromArgs(["--log-level=warn", "test"]);
441
+ expect(AppContext.current.logger.getMinLevel()).toBe(LogLevel.warn);
423
442
  });
424
443
  });
425
444
  });