@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
@@ -1,44 +1,28 @@
1
- import { createCliRenderer } from "@opentui/core";
2
- import { createRoot } from "@opentui/react";
3
- import { Application, type ApplicationConfig } from "../core/application.ts";
1
+ import { createRenderer } from "./adapters/factory.ts";
2
+ import { RendererProvider } from "./context/RendererContext.tsx";
3
+ import { Application, type ModeOptions, type ApplicationConfig, type TuiModeOptions } from "../core/application.ts";
4
4
  import type { AnyCommand } from "../core/command.ts";
5
- import { TuiApp } from "./TuiApp.tsx";
6
- import { Theme } from "./theme.ts";
7
- import type { LogSource, LogEvent } from "./hooks/index.ts";
8
- import { LogLevel as TuiLogLevel } from "./hooks/index.ts";
9
- import { LogLevel as CoreLogLevel, type LogEvent as CoreLogEvent } from "../core/logger.ts";
10
- import type { FieldConfig } from "./components/types.ts";
5
+ import { TuiRoot } from "./TuiRoot.tsx";
6
+ import { LogLevel } from "../core/logger.ts";
11
7
  import { createSettingsCommand } from "../builtins/settings.ts";
8
+ import { KNOWN_COMMANDS } from "../core/knownCommands.ts";
12
9
  import { loadPersistedParameters } from "./utils/parameterPersistence.ts";
13
-
14
- /**
15
- * Custom field configuration for TUI forms.
16
- * Allows adding application-specific fields that aren't part of command options.
17
- */
18
- export interface CustomField extends FieldConfig {
19
- /** Default value for the field */
20
- default?: unknown;
21
- /** Called when the field value changes */
22
- onChange?: (value: unknown, allValues: Record<string, unknown>) => void;
23
- }
10
+ import { AppContext } from "../core/context.ts";
11
+ import { registerAllModals, registerAllScreens } from "./registry.ts";
24
12
 
25
13
  /**
26
14
  * Extended configuration for TUI-enabled applications.
27
15
  */
28
16
  export interface TuiApplicationConfig extends ApplicationConfig {
29
- /** Enable interactive TUI mode */
17
+ /** Enable TUI mode (when renderer is opentui/ink/default) */
30
18
  enableTui?: boolean;
31
- /** Log source for TUI log panel */
32
- logSource?: LogSource;
33
- /** Custom fields to add to the TUI form */
34
- customFields?: CustomField[];
35
19
  }
36
20
 
37
21
  /**
38
22
  * Application class with built-in TUI support.
39
23
  *
40
24
  * Extends the base Application to provide automatic TUI rendering
41
- * when running interactively or with the --interactive flag.
25
+ * when running with `--renderer` set to a TUI renderer (or default).
42
26
  *
43
27
  * @example
44
28
  * ```typescript
@@ -53,19 +37,15 @@ export interface TuiApplicationConfig extends ApplicationConfig {
53
37
  * }
54
38
  * }
55
39
  *
56
- * await new MyApp().run(process.argv.slice(2));
40
+ * await new MyApp().run();
57
41
  * ```
58
42
  */
59
43
  export class TuiApplication extends Application {
60
44
  private readonly enableTui: boolean;
61
- private readonly logSource?: LogSource;
62
- private readonly customFields?: CustomField[];
63
45
 
64
46
  constructor(config: TuiApplicationConfig) {
65
47
  super(config);
66
48
  this.enableTui = config.enableTui ?? true;
67
- this.logSource = config.logSource;
68
- this.customFields = config.customFields;
69
49
  }
70
50
 
71
51
  /**
@@ -74,130 +54,92 @@ export class TuiApplication extends Application {
74
54
  * If no arguments are provided and TUI is enabled, launches the TUI.
75
55
  * Otherwise, runs in CLI mode.
76
56
  */
77
- override async run(argv: string[] = process.argv.slice(2)): Promise<void> {
78
- // Check for --interactive or -i flag
79
- const hasInteractiveFlag = argv.includes("--interactive") || argv.includes("-i");
80
- const filteredArgs = argv.filter((arg) => arg !== "--interactive" && arg !== "-i");
81
-
82
- // Launch TUI if:
83
- // 1. Explicit --interactive flag, or
84
- // 2. No args and TUI is enabled
85
- if (hasInteractiveFlag || (filteredArgs.length === 0 && this.enableTui)) {
86
- await this.runTui();
57
+ override async run(): Promise<void> {
58
+ return this.runFromArgs(Bun.argv.slice(2));
59
+ }
60
+
61
+ override async runFromArgs(argv: string[]): Promise<void> {
62
+ const { globalOptions } = this.parseGlobalOptions(argv);
63
+
64
+ const mode = globalOptions["mode"] as ModeOptions ?? "default";
65
+ const resolvedMode = mode === "default" ? this.defaultMode : mode;
66
+
67
+ if (resolvedMode === "cli") {
68
+ await super.runFromArgs(argv);
69
+ return;
70
+ }
71
+
72
+ if (!this.enableTui) {
73
+ throw new Error("TUI mode is disabled for this application");
74
+ }
75
+
76
+ if (resolvedMode === "opentui" || resolvedMode === "ink") {
77
+ this.applyGlobalOptions(globalOptions);
78
+
79
+ await this.runTui(resolvedMode);
87
80
  return;
88
81
  }
89
82
 
90
- // Otherwise run CLI mode
91
- await super.run(filteredArgs);
83
+ throw new Error(`Unknown mode '${resolvedMode}'`);
92
84
  }
93
85
 
94
86
  /**
95
- * Launch the interactive TUI.
87
+ * Launch the TUI.
96
88
  */
97
- async runTui(): Promise<void> {
89
+ async runTui(rendererType: TuiModeOptions): Promise<void> {
90
+ await registerAllScreens();
91
+ await registerAllModals();
92
+
98
93
  // Get all commands that support TUI or have options
99
94
  const commands = this.getExecutableCommands();
100
95
 
101
96
  // Load and apply persisted settings (log-level, detailed-logs)
102
97
  this.loadPersistedSettings();
103
98
 
104
- // Enable TUI mode on the logger so logs go to the event emitter
105
- // instead of stderr (which would corrupt the TUI display)
106
- this.context.logger.setTuiMode(true);
107
-
108
- // Create a log source from the logger if one wasn't provided
109
- const logSource = this.logSource ?? this.createLogSourceFromLogger();
110
-
111
- const renderer = await createCliRenderer({
99
+ const renderer = await createRenderer(rendererType, {
112
100
  useAlternateScreen: true,
113
- useConsole: false,
114
- exitOnCtrlC: true,
115
- backgroundColor: Theme.background,
116
- useMouse: true,
117
- enableMouseMovement: true,
118
- openConsoleOnError: false,
119
101
  });
120
102
 
121
103
  return new Promise<void>((resolve) => {
122
104
  const handleExit = () => {
123
- // Restore CLI mode on exit
124
- this.context.logger.setTuiMode(false);
125
105
  renderer.destroy();
126
106
  resolve();
127
107
  };
128
108
 
129
- const root = createRoot(renderer);
130
- root.render(
131
- <TuiApp
132
- name={this.name}
133
- displayName={this.displayName}
134
- version={this.version}
135
- commands={commands}
136
- context={this.context}
137
- logSource={logSource}
138
- customFields={this.customFields}
139
- onExit={handleExit}
140
- />
109
+ renderer.render(
110
+ <RendererProvider renderer={renderer}>
111
+ <TuiRoot
112
+ name={this.name}
113
+ displayName={this.displayName}
114
+ version={this.version}
115
+ commands={commands}
116
+ onExit={handleExit}
117
+ />
118
+ </RendererProvider>
141
119
  );
142
-
143
- renderer.start();
144
120
  });
145
121
  }
146
122
 
147
- /**
148
- * Create a LogSource adapter from the application logger.
149
- */
150
- private createLogSourceFromLogger(): LogSource {
151
- const logger = this.context.logger;
152
-
153
- // Map core log levels to TUI log levels
154
- const mapLogLevel = (level: CoreLogLevel): TuiLogLevel => {
155
- switch (level) {
156
- case CoreLogLevel.Silly: return TuiLogLevel.Silly;
157
- case CoreLogLevel.Trace: return TuiLogLevel.Trace;
158
- case CoreLogLevel.Debug: return TuiLogLevel.Debug;
159
- case CoreLogLevel.Info: return TuiLogLevel.Info;
160
- case CoreLogLevel.Warn: return TuiLogLevel.Warn;
161
- case CoreLogLevel.Error: return TuiLogLevel.Error;
162
- case CoreLogLevel.Fatal: return TuiLogLevel.Fatal;
163
- default: return TuiLogLevel.Info;
164
- }
165
- };
166
-
167
- return {
168
- subscribe: (callback: (event: LogEvent) => void) => {
169
- return logger.onLogEvent((coreEvent: CoreLogEvent) => {
170
- callback({
171
- level: mapLogLevel(coreEvent.level),
172
- message: coreEvent.message,
173
- });
174
- });
175
- },
176
- };
177
- }
178
-
179
123
  /**
180
124
  * Load persisted settings and apply them to the logger.
181
125
  * Settings are saved when the user uses the Settings command.
182
126
  */
183
127
  private loadPersistedSettings(): void {
184
128
  try {
185
- const settings = loadPersistedParameters(this.name, "settings");
186
-
129
+ const settings = loadPersistedParameters(this.name, KNOWN_COMMANDS.settings);
130
+
187
131
  // Apply log-level if set
188
132
  if (settings["log-level"]) {
189
133
  const levelStr = String(settings["log-level"]).toLowerCase();
190
- const level = Object.entries(CoreLogLevel).find(
191
- ([key, val]) => typeof val === "number" && key.toLowerCase() === levelStr
192
- )?.[1] as CoreLogLevel | undefined;
134
+ const level = LogLevel[levelStr as keyof typeof LogLevel];
193
135
  if (level !== undefined) {
194
- this.context.logger.setMinLevel(level);
136
+ AppContext.current.logger.setMinLevel(level);
195
137
  }
196
138
  }
197
-
139
+
198
140
  // Apply detailed-logs if set
199
141
  if (settings["detailed-logs"] !== undefined) {
200
- this.context.logger.setDetailed(Boolean(settings["detailed-logs"]));
142
+ AppContext.current.logger.setDetailed(Boolean(settings["detailed-logs"]));
201
143
  }
202
144
  } catch {
203
145
  // Silently ignore errors loading settings
@@ -212,15 +154,16 @@ export class TuiApplication extends Application {
212
154
  const userCommands = this.registry
213
155
  .list()
214
156
  .filter((cmd) => {
215
- // Exclude version and help from main menu
216
- if (cmd.name === "version" || cmd.name === "help") {
157
+ // Exclude internal/built-in commands from the TUI main menu
158
+ if (cmd.tuiHidden) {
217
159
  return false;
218
160
  }
219
- // Exclude settings if already defined by user (they shouldn't)
220
- if (cmd.name === "settings") {
161
+
162
+ // Extra safety: keep known internal command names out
163
+ if (cmd.name === KNOWN_COMMANDS.help || cmd.name === KNOWN_COMMANDS.version || cmd.name === KNOWN_COMMANDS.settings) {
221
164
  return false;
222
165
  }
223
- // Include commands that have options or execute methods
166
+
224
167
  return true;
225
168
  });
226
169
 
@@ -0,0 +1,135 @@
1
+ import type { AnyCommand } from "../core/command.ts";
2
+ import { useClipboard } from "./hooks/useClipboard.ts";
3
+ import { KeyboardProvider } from "./context/KeyboardContext.tsx";
4
+ import { useGlobalKeyHandler } from "./hooks/useGlobalKeyHandler.ts";
5
+ import { LogsProvider } from "./context/LogsContext.tsx";
6
+ import { NavigationProvider, useNavigation } from "./context/NavigationContext.tsx";
7
+ import { ClipboardProviderComponent, useClipboardContext } from "./context/ClipboardContext.tsx";
8
+ import { TuiAppContextProvider, useTuiApp } from "./context/TuiAppContext.tsx";
9
+ import { ExecutorProvider, useExecutor } from "./context/ExecutorContext.tsx";
10
+ import { Header } from "./components/Header.tsx";
11
+ import { StatusBar } from "./components/StatusBar.tsx";
12
+ import { Container } from "./semantic/Container.tsx";
13
+ import { Panel } from "./semantic/Panel.tsx";
14
+ import { getScreen, getModal } from "./registry.ts";
15
+ import { CommandSelectScreen, type CommandSelectParams } from "./screens/CommandSelectScreen.tsx";
16
+
17
+ interface TuiRootProps {
18
+ name: string;
19
+ displayName?: string;
20
+ version: string;
21
+ commands: AnyCommand[];
22
+ onExit: () => void;
23
+ }
24
+
25
+ export function TuiRoot({ name, displayName, version, commands, onExit }: TuiRootProps) {
26
+ return (
27
+ <KeyboardProvider>
28
+ <ClipboardProviderComponent>
29
+ <TuiAppContextProvider
30
+ name={name}
31
+ displayName={displayName}
32
+ version={version}
33
+ commands={commands}
34
+ onExit={onExit}
35
+ >
36
+ <LogsProvider>
37
+ <ExecutorProvider>
38
+ <NavigationProvider<CommandSelectParams>
39
+ initialScreen={{ route: CommandSelectScreen.Id, params: { commandPath: [] } }}
40
+ onExit={onExit}
41
+ >
42
+ <TuiRootContent />
43
+ </NavigationProvider>
44
+ </ExecutorProvider>
45
+ </LogsProvider>
46
+ </TuiAppContextProvider>
47
+ </ClipboardProviderComponent>
48
+ </KeyboardProvider>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Main TUI content - renders current screen, modals, and handles global shortcuts.
54
+ * This component knows NOTHING about specific screens or their logic.
55
+ */
56
+ function TuiRootContent() {
57
+ const { displayName, name, version } = useTuiApp();
58
+ const navigation = useNavigation();
59
+ const executor = useExecutor();
60
+ const clipboard = useClipboardContext();
61
+ const { copyWithMessage, lastAction } = useClipboard();
62
+
63
+
64
+
65
+ // Global keyboard handler - only truly global shortcuts
66
+ useGlobalKeyHandler((key) => {
67
+ // Esc - back/close (delegates to navigation which delegates to screen)
68
+ if (key.name === "escape") {
69
+ navigation.goBack();
70
+ return true;
71
+ }
72
+
73
+ // Ctrl+Y - copy
74
+ if (key.ctrl && key.name === "y") {
75
+ const content = clipboard.getContent();
76
+ if (content) {
77
+ copyWithMessage(content.content, content.label);
78
+ }
79
+ return true;
80
+ }
81
+
82
+ // Ctrl+L - toggle logs modal
83
+ if (key.ctrl && key.name === "l") {
84
+ const isLogsOpen = navigation.modalStack.some((m) => m.id === "logs");
85
+ if (isLogsOpen) {
86
+ navigation.closeModal();
87
+ } else {
88
+ navigation.openModal("logs");
89
+
90
+ }
91
+ return true;
92
+ }
93
+
94
+ return false;
95
+ });
96
+
97
+ // Get current screen component from registry
98
+ const ScreenComponent = getScreen(navigation.current.route);
99
+
100
+ // Get breadcrumb from current screen params (if available)
101
+ const params = navigation.current.params as { commandPath?: string[] } | undefined;
102
+ const breadcrumb = params?.commandPath;
103
+
104
+ return (
105
+ <Panel flexDirection="column" flex={1} padding={1} border={false}>
106
+ <Container flexDirection="column" flex={1}>
107
+ <Header name={displayName ?? name} version={version} breadcrumb={breadcrumb} />
108
+
109
+ <Container flexDirection="column" flex={1}>
110
+ {ScreenComponent ? <ScreenComponent /> : null}
111
+ </Container>
112
+
113
+ <StatusBar
114
+ status={lastAction || (executor.isExecuting ? "Executing..." : "Ready")}
115
+ isRunning={executor.isExecuting}
116
+ shortcuts="Esc Back • Ctrl+Y Copy • Ctrl+L Logs"
117
+ />
118
+
119
+ {/* Render modals from registry */}
120
+ {navigation.modalStack.map((modal, idx) => {
121
+ const ModalComponent = getModal(modal.id);
122
+ if (!ModalComponent) return null;
123
+
124
+ return (
125
+ <ModalComponent
126
+ key={`modal-${modal.id}-${idx}`}
127
+ params={modal.params}
128
+ onClose={() => navigation.closeModal()}
129
+ />
130
+ );
131
+ })}
132
+ </Container>
133
+ </Panel>
134
+ );
135
+ }
@@ -0,0 +1,19 @@
1
+ import type { Renderer, RendererConfig } from "./types.ts";
2
+ import { OpenTuiRenderer } from "./opentui/OpenTuiRenderer.tsx";
3
+ import { InkRenderer } from "./ink/InkRenderer.tsx";
4
+ import type { TuiModeOptions } from "../../core/application.ts";
5
+
6
+ export async function createRenderer(type: TuiModeOptions, config: RendererConfig): Promise<Renderer> {
7
+ switch (type) {
8
+ case "opentui": {
9
+ const renderer = new OpenTuiRenderer(config);
10
+ await renderer.initialize();
11
+ return renderer;
12
+ }
13
+ case "ink": {
14
+ const renderer = new InkRenderer(config);
15
+ await renderer.initialize();
16
+ return renderer;
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,135 @@
1
+ import { render } from "ink";
2
+ import type { ReactNode } from "react";
3
+ import { useLayoutEffect } from "react";
4
+
5
+ import type { Renderer, RendererConfig } from "../types.ts";
6
+ import { useInkKeyboardAdapter } from "./keyboard.ts";
7
+
8
+ import { Button } from "./components/Button.tsx";
9
+ import { Container } from "./components/Container.tsx";
10
+ import { Field } from "./components/Field.tsx";
11
+ import { Label } from "./components/Label.tsx";
12
+ import { MenuButton } from "./components/MenuButton.tsx";
13
+ import { MenuItem } from "./components/MenuItem.tsx";
14
+ import { Overlay } from "./components/Overlay.tsx";
15
+ import { Panel } from "./components/Panel.tsx";
16
+ import { ScrollView } from "./components/ScrollView.tsx";
17
+ import { Select } from "./components/Select.tsx";
18
+ import { Spacer } from "./components/Spacer.tsx";
19
+ import { Spinner } from "./components/Spinner.tsx";
20
+ import { TextInput } from "./components/TextInput.tsx";
21
+ import { Value } from "./components/Value.tsx";
22
+ import { Code } from "./components/Code.tsx";
23
+ import { CodeHighlight } from "./components/CodeHighlight.tsx";
24
+
25
+ export class InkRenderer implements Renderer {
26
+ private instance: ReturnType<typeof render> | null = null;
27
+ private activeKeyboardAdapter: Renderer["keyboard"] | null = null;
28
+
29
+ public keyboard: Renderer["keyboard"] = {
30
+ setActiveHandler: (id, handler) => {
31
+ return this.activeKeyboardAdapter?.setActiveHandler(id, handler) ?? (() => {});
32
+ },
33
+ setGlobalHandler: (handler) => {
34
+ return this.activeKeyboardAdapter?.setGlobalHandler(handler) ?? (() => {});
35
+ },
36
+ };
37
+
38
+ public components: Renderer["components"] = {
39
+ Field,
40
+ Button,
41
+ MenuButton,
42
+ MenuItem,
43
+
44
+ Container,
45
+ Panel,
46
+ ScrollView,
47
+
48
+ Overlay,
49
+ Spacer,
50
+ Spinner,
51
+
52
+ Label,
53
+ Value,
54
+ Code,
55
+ CodeHighlight,
56
+
57
+ Select,
58
+ TextInput,
59
+ };
60
+
61
+ constructor(_config: RendererConfig) {}
62
+
63
+ async initialize(): Promise<void> {
64
+ return;
65
+ }
66
+
67
+ render(node: ReactNode): void {
68
+ if (process.stdin.isTTY) {
69
+ try {
70
+ process.stdin.setRawMode(true);
71
+ } catch {
72
+ // Ignore.
73
+ }
74
+ if (process.stdin.isPaused()) {
75
+ process.stdin.resume();
76
+ }
77
+ }
78
+
79
+ if (this.instance) {
80
+ this.instance.rerender(
81
+ <KeyboardBridge
82
+ node={node}
83
+ onReady={(keyboard) => {
84
+ this.activeKeyboardAdapter = keyboard;
85
+ }}
86
+ />
87
+ );
88
+ return;
89
+ }
90
+
91
+ this.instance = render(
92
+ <KeyboardBridge
93
+ node={node}
94
+ onReady={(keyboard) => {
95
+ this.activeKeyboardAdapter = keyboard;
96
+ }}
97
+ />,
98
+ {
99
+ exitOnCtrlC: true,
100
+ patchConsole: false,
101
+ stdout: process.stdout,
102
+ stdin: process.stdin,
103
+ }
104
+ );
105
+ }
106
+
107
+ destroy(): void {
108
+ this.instance?.unmount();
109
+ this.instance = null;
110
+
111
+ if (process.stdin.isTTY) {
112
+ try {
113
+ process.stdin.setRawMode(false);
114
+ } catch {
115
+ // Ignore.
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ function KeyboardBridge({
122
+ node,
123
+ onReady,
124
+ }: {
125
+ node: ReactNode;
126
+ onReady: (keyboard: ReturnType<typeof useInkKeyboardAdapter>) => void;
127
+ }) {
128
+ const keyboard = useInkKeyboardAdapter();
129
+
130
+ useLayoutEffect(() => {
131
+ onReady(keyboard);
132
+ }, [onReady, keyboard]);
133
+
134
+ return node;
135
+ }
@@ -0,0 +1,12 @@
1
+ import { Text } from "ink";
2
+ import type { ButtonProps } from "../../../semantic/types.ts";
3
+
4
+ export function Button({ label, selected }: ButtonProps) {
5
+ const prefix = selected ? "> " : " ";
6
+ return (
7
+ <Text>
8
+ {prefix}
9
+ {label}
10
+ </Text>
11
+ );
12
+ }
@@ -0,0 +1,6 @@
1
+ import { Text } from "ink";
2
+ import type { CodeProps } from "../../../semantic/types.ts";
3
+
4
+ export function Code({ children }: CodeProps) {
5
+ return <Text color="gray">{children}</Text>;
6
+ }
@@ -0,0 +1,6 @@
1
+ import { Text } from "ink";
2
+ import type { CodeHighlightProps } from "../../../semantic/types.ts";
3
+
4
+ export function CodeHighlight({ tokens }: CodeHighlightProps) {
5
+ return <Text>{tokens.map((t) => t.value).join("")}</Text>;
6
+ }
@@ -0,0 +1,5 @@
1
+ import type { ContainerProps } from "../../../semantic/types.ts";
2
+
3
+ export function Container({ children }: ContainerProps) {
4
+ return <>{children}</>;
5
+ }
@@ -0,0 +1,12 @@
1
+ import { Text } from "ink";
2
+ import type { FieldProps } from "../../../semantic/types.ts";
3
+
4
+ export function Field({ label, value, selected }: FieldProps) {
5
+ const prefix = selected ? "> " : " ";
6
+ return (
7
+ <Text>
8
+ {prefix}
9
+ {label}: {value as any}
10
+ </Text>
11
+ );
12
+ }
@@ -0,0 +1,24 @@
1
+ import { Text } from "ink";
2
+ import type { LabelProps } from "../../../semantic/types.ts";
3
+ import { toPlainText } from "../utils.ts";
4
+
5
+ const COLOR_MAP: Record<string, string> = {
6
+ text: "white",
7
+ mutedText: "gray",
8
+ primary: "cyan",
9
+ success: "green",
10
+ warning: "yellow",
11
+ error: "red",
12
+ value: "magenta",
13
+ code: "gray",
14
+ };
15
+
16
+ export function Label({ color, bold, italic, children }: LabelProps) {
17
+ const text = toPlainText(children);
18
+ const inkColor = color ? (COLOR_MAP[color] ?? color) : undefined;
19
+ return (
20
+ <Text color={inkColor} bold={bold} italic={italic}>
21
+ {text}
22
+ </Text>
23
+ );
24
+ }
@@ -0,0 +1,12 @@
1
+ import { Text } from "ink";
2
+ import type { MenuButtonProps } from "../../../semantic/types.ts";
3
+
4
+ export function MenuButton({ label, selected }: MenuButtonProps) {
5
+ const prefix = selected ? "> " : " ";
6
+ return (
7
+ <Text>
8
+ {prefix}
9
+ {label}
10
+ </Text>
11
+ );
12
+ }
@@ -0,0 +1,17 @@
1
+ import { Text } from "ink";
2
+ import type { MenuItemProps } from "../../../semantic/types.ts";
3
+
4
+ export function MenuItem({ label, description, suffix, selected }: MenuItemProps) {
5
+ const prefix = selected ? "> " : " ";
6
+ const desc = description ? ` — ${description}` : "";
7
+ const suffixText = suffix ? ` ${suffix}` : "";
8
+
9
+ return (
10
+ <Text>
11
+ {prefix}
12
+ {label}
13
+ {desc}
14
+ {suffixText}
15
+ </Text>
16
+ );
17
+ }
@@ -0,0 +1,5 @@
1
+ import type { OverlayProps } from "../../../semantic/types.ts";
2
+
3
+ export function Overlay({ children }: OverlayProps) {
4
+ return <>{children}</>;
5
+ }