@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.
- package/.devcontainer/devcontainer.json +19 -0
- package/.devcontainer/install-prerequisites.sh +49 -0
- package/.github/workflows/copilot-setup-steps.yml +32 -0
- package/.github/workflows/pull-request.yml +27 -0
- package/.github/workflows/release-npm-package.yml +78 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/examples/tui-app/commands/greet.ts +75 -0
- package/examples/tui-app/commands/index.ts +3 -0
- package/examples/tui-app/commands/math.ts +114 -0
- package/examples/tui-app/commands/status.ts +75 -0
- package/examples/tui-app/index.ts +34 -0
- package/guides/01-hello-world.md +96 -0
- package/guides/02-adding-options.md +103 -0
- package/guides/03-multiple-commands.md +163 -0
- package/guides/04-subcommands.md +206 -0
- package/guides/05-interactive-tui.md +194 -0
- package/guides/06-config-validation.md +264 -0
- package/guides/07-async-cancellation.md +388 -0
- package/guides/08-complete-application.md +673 -0
- package/guides/README.md +74 -0
- package/package.json +32 -0
- package/src/__tests__/application.test.ts +425 -0
- package/src/__tests__/buildCliCommand.test.ts +125 -0
- package/src/__tests__/builtins.test.ts +133 -0
- package/src/__tests__/colors.test.ts +127 -0
- package/src/__tests__/command.test.ts +157 -0
- package/src/__tests__/commandClass.test.ts +130 -0
- package/src/__tests__/context.test.ts +97 -0
- package/src/__tests__/help.test.ts +412 -0
- package/src/__tests__/parser.test.ts +268 -0
- package/src/__tests__/registry.test.ts +195 -0
- package/src/__tests__/registryNew.test.ts +160 -0
- package/src/__tests__/schemaToFields.test.ts +176 -0
- package/src/__tests__/table.test.ts +146 -0
- package/src/__tests__/tui.test.ts +26 -0
- package/src/builtins/help.ts +85 -0
- package/src/builtins/index.ts +4 -0
- package/src/builtins/settings.ts +106 -0
- package/src/builtins/version.ts +72 -0
- package/src/cli/help.ts +174 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/output/colors.ts +74 -0
- package/src/cli/output/index.ts +2 -0
- package/src/cli/output/table.ts +141 -0
- package/src/cli/parser.ts +241 -0
- package/src/commands/help.ts +50 -0
- package/src/commands/index.ts +1 -0
- package/src/components/index.ts +147 -0
- package/src/core/application.ts +461 -0
- package/src/core/command.ts +269 -0
- package/src/core/context.ts +112 -0
- package/src/core/help.ts +214 -0
- package/src/core/index.ts +15 -0
- package/src/core/logger.ts +164 -0
- package/src/core/registry.ts +140 -0
- package/src/hooks/index.ts +131 -0
- package/src/index.ts +137 -0
- package/src/registry/commandRegistry.ts +77 -0
- package/src/registry/index.ts +1 -0
- package/src/tui/TuiApp.tsx +582 -0
- package/src/tui/TuiApplication.tsx +230 -0
- package/src/tui/app.ts +29 -0
- package/src/tui/components/ActionButton.tsx +36 -0
- package/src/tui/components/CliModal.tsx +81 -0
- package/src/tui/components/CommandSelector.tsx +159 -0
- package/src/tui/components/ConfigForm.tsx +148 -0
- package/src/tui/components/EditorModal.tsx +177 -0
- package/src/tui/components/FieldRow.tsx +30 -0
- package/src/tui/components/Header.tsx +31 -0
- package/src/tui/components/JsonHighlight.tsx +128 -0
- package/src/tui/components/LogsPanel.tsx +86 -0
- package/src/tui/components/ResultsPanel.tsx +93 -0
- package/src/tui/components/StatusBar.tsx +59 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/components/types.ts +30 -0
- package/src/tui/context/KeyboardContext.tsx +118 -0
- package/src/tui/context/index.ts +7 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useClipboard.ts +66 -0
- package/src/tui/hooks/useCommandExecutor.ts +131 -0
- package/src/tui/hooks/useConfigState.ts +171 -0
- package/src/tui/hooks/useKeyboardHandler.ts +91 -0
- package/src/tui/hooks/useLogStream.ts +96 -0
- package/src/tui/hooks/useSpinner.ts +46 -0
- package/src/tui/index.ts +65 -0
- package/src/tui/theme.ts +21 -0
- package/src/tui/utils/buildCliCommand.ts +90 -0
- package/src/tui/utils/index.ts +13 -0
- package/src/tui/utils/parameterPersistence.ts +96 -0
- package/src/tui/utils/schemaToFields.ts +144 -0
- package/src/types/command.ts +103 -0
- package/src/types/execution.ts +11 -0
- package/src/types/index.ts +1 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createCliRenderer } from "@opentui/core";
|
|
2
|
+
import { createRoot } from "@opentui/react";
|
|
3
|
+
import { Application, type ApplicationConfig } from "../core/application.ts";
|
|
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";
|
|
11
|
+
import { createSettingsCommand } from "../builtins/settings.ts";
|
|
12
|
+
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
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extended configuration for TUI-enabled applications.
|
|
27
|
+
*/
|
|
28
|
+
export interface TuiApplicationConfig extends ApplicationConfig {
|
|
29
|
+
/** Enable interactive TUI mode */
|
|
30
|
+
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
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Application class with built-in TUI support.
|
|
39
|
+
*
|
|
40
|
+
* Extends the base Application to provide automatic TUI rendering
|
|
41
|
+
* when running interactively or with the --interactive flag.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* class MyApp extends TuiApplication {
|
|
46
|
+
* constructor() {
|
|
47
|
+
* super({
|
|
48
|
+
* name: "myapp",
|
|
49
|
+
* version: "1.0.0",
|
|
50
|
+
* commands: [new RunCommand(), new ConfigCommand()],
|
|
51
|
+
* enableTui: true,
|
|
52
|
+
* });
|
|
53
|
+
* }
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* await new MyApp().run(process.argv.slice(2));
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export class TuiApplication extends Application {
|
|
60
|
+
private readonly enableTui: boolean;
|
|
61
|
+
private readonly logSource?: LogSource;
|
|
62
|
+
private readonly customFields?: CustomField[];
|
|
63
|
+
|
|
64
|
+
constructor(config: TuiApplicationConfig) {
|
|
65
|
+
super(config);
|
|
66
|
+
this.enableTui = config.enableTui ?? true;
|
|
67
|
+
this.logSource = config.logSource;
|
|
68
|
+
this.customFields = config.customFields;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the application.
|
|
73
|
+
*
|
|
74
|
+
* If no arguments are provided and TUI is enabled, launches the TUI.
|
|
75
|
+
* Otherwise, runs in CLI mode.
|
|
76
|
+
*/
|
|
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();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Otherwise run CLI mode
|
|
91
|
+
await super.run(filteredArgs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Launch the interactive TUI.
|
|
96
|
+
*/
|
|
97
|
+
async runTui(): Promise<void> {
|
|
98
|
+
// Get all commands that support TUI or have options
|
|
99
|
+
const commands = this.getExecutableCommands();
|
|
100
|
+
|
|
101
|
+
// Load and apply persisted settings (log-level, detailed-logs)
|
|
102
|
+
this.loadPersistedSettings();
|
|
103
|
+
|
|
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({
|
|
112
|
+
useAlternateScreen: true,
|
|
113
|
+
useConsole: false,
|
|
114
|
+
exitOnCtrlC: true,
|
|
115
|
+
backgroundColor: Theme.background,
|
|
116
|
+
useMouse: true,
|
|
117
|
+
enableMouseMovement: true,
|
|
118
|
+
openConsoleOnError: false,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return new Promise<void>((resolve) => {
|
|
122
|
+
const handleExit = () => {
|
|
123
|
+
// Restore CLI mode on exit
|
|
124
|
+
this.context.logger.setTuiMode(false);
|
|
125
|
+
renderer.destroy();
|
|
126
|
+
resolve();
|
|
127
|
+
};
|
|
128
|
+
|
|
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
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
renderer.start();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
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
|
+
/**
|
|
180
|
+
* Load persisted settings and apply them to the logger.
|
|
181
|
+
* Settings are saved when the user uses the Settings command.
|
|
182
|
+
*/
|
|
183
|
+
private loadPersistedSettings(): void {
|
|
184
|
+
try {
|
|
185
|
+
const settings = loadPersistedParameters(this.name, "settings");
|
|
186
|
+
|
|
187
|
+
// Apply log-level if set
|
|
188
|
+
if (settings["log-level"]) {
|
|
189
|
+
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;
|
|
193
|
+
if (level !== undefined) {
|
|
194
|
+
this.context.logger.setMinLevel(level);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Apply detailed-logs if set
|
|
199
|
+
if (settings["detailed-logs"] !== undefined) {
|
|
200
|
+
this.context.logger.setDetailed(Boolean(settings["detailed-logs"]));
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Silently ignore errors loading settings
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get commands that can be used in TUI.
|
|
209
|
+
* Filters out internal commands like version/help, and adds built-in settings.
|
|
210
|
+
*/
|
|
211
|
+
private getExecutableCommands(): AnyCommand[] {
|
|
212
|
+
const userCommands = this.registry
|
|
213
|
+
.list()
|
|
214
|
+
.filter((cmd) => {
|
|
215
|
+
// Exclude version and help from main menu
|
|
216
|
+
if (cmd.name === "version" || cmd.name === "help") {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
// Exclude settings if already defined by user (they shouldn't)
|
|
220
|
+
if (cmd.name === "settings") {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
// Include commands that have options or execute methods
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Add built-in settings command at the end
|
|
228
|
+
return [...userCommands, createSettingsCommand()];
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App configuration
|
|
3
|
+
*/
|
|
4
|
+
export interface AppConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* App state
|
|
12
|
+
*/
|
|
13
|
+
export interface AppState {
|
|
14
|
+
currentView: string;
|
|
15
|
+
isLoading: boolean;
|
|
16
|
+
error?: Error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a TUI application
|
|
21
|
+
*/
|
|
22
|
+
export function createApp(_config: AppConfig) {
|
|
23
|
+
return {
|
|
24
|
+
run: async () => {
|
|
25
|
+
// Placeholder for TUI app runner
|
|
26
|
+
console.log("TUI app started");
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
|
|
3
|
+
interface ActionButtonProps {
|
|
4
|
+
/** Button label */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Whether this button is selected */
|
|
7
|
+
isSelected: boolean;
|
|
8
|
+
/** Optional spinner frame for loading state */
|
|
9
|
+
spinnerFrame?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Action button displayed at the bottom of a config form.
|
|
14
|
+
*/
|
|
15
|
+
export function ActionButton({ label, isSelected, spinnerFrame }: ActionButtonProps) {
|
|
16
|
+
const prefix = isSelected ? "► " : " ";
|
|
17
|
+
const displayLabel = spinnerFrame ? `${spinnerFrame} ${label}...` : `[ ${label} ]`;
|
|
18
|
+
|
|
19
|
+
if (isSelected) {
|
|
20
|
+
return (
|
|
21
|
+
<box marginTop={1}>
|
|
22
|
+
<text fg="#000000" bg={Theme.actionButton}>
|
|
23
|
+
{prefix}{displayLabel}
|
|
24
|
+
</text>
|
|
25
|
+
</box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<box marginTop={1}>
|
|
31
|
+
<text fg={Theme.actionButton}>
|
|
32
|
+
{prefix}{displayLabel}
|
|
33
|
+
</text>
|
|
34
|
+
</box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
|
|
3
|
+
|
|
4
|
+
interface CliModalProps {
|
|
5
|
+
/** CLI command to display */
|
|
6
|
+
command: string;
|
|
7
|
+
/** Whether the modal is visible */
|
|
8
|
+
visible: boolean;
|
|
9
|
+
/** Called when the modal should close */
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
/** Called when the command should be copied */
|
|
12
|
+
onCopy?: (content: string, label: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Modal displaying the CLI command equivalent of the current config.
|
|
17
|
+
*/
|
|
18
|
+
export function CliModal({
|
|
19
|
+
command,
|
|
20
|
+
visible,
|
|
21
|
+
onClose,
|
|
22
|
+
onCopy,
|
|
23
|
+
}: CliModalProps) {
|
|
24
|
+
// Modal keyboard handler
|
|
25
|
+
useKeyboardHandler(
|
|
26
|
+
(event) => {
|
|
27
|
+
const { key } = event;
|
|
28
|
+
|
|
29
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter") {
|
|
30
|
+
onClose();
|
|
31
|
+
event.stopPropagation();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Y to copy
|
|
36
|
+
if (key.name === "y") {
|
|
37
|
+
onCopy?.(command, "CLI command");
|
|
38
|
+
event.stopPropagation();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
KeyboardPriority.Modal,
|
|
43
|
+
{ enabled: visible, modal: true }
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!visible) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<box
|
|
52
|
+
position="absolute"
|
|
53
|
+
top={4}
|
|
54
|
+
left={4}
|
|
55
|
+
width="80%"
|
|
56
|
+
height={10}
|
|
57
|
+
backgroundColor={Theme.overlay}
|
|
58
|
+
border={true}
|
|
59
|
+
borderStyle="rounded"
|
|
60
|
+
borderColor={Theme.overlayTitle}
|
|
61
|
+
padding={1}
|
|
62
|
+
flexDirection="column"
|
|
63
|
+
gap={1}
|
|
64
|
+
zIndex={20}
|
|
65
|
+
>
|
|
66
|
+
<text fg={Theme.overlayTitle}>
|
|
67
|
+
<strong>CLI Command</strong>
|
|
68
|
+
</text>
|
|
69
|
+
|
|
70
|
+
<scrollbox scrollX={true} height={3}>
|
|
71
|
+
<text fg={Theme.value}>
|
|
72
|
+
{command}
|
|
73
|
+
</text>
|
|
74
|
+
</scrollbox>
|
|
75
|
+
|
|
76
|
+
<text fg={Theme.statusText}>
|
|
77
|
+
Ctrl+Y to copy • Enter or Esc to close
|
|
78
|
+
</text>
|
|
79
|
+
</box>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
|
|
3
|
+
import type { Command } from "../../core/command.ts";
|
|
4
|
+
|
|
5
|
+
interface CommandItem {
|
|
6
|
+
/** The command object */
|
|
7
|
+
command: Command;
|
|
8
|
+
/** Display label (defaults to command name) */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Description (defaults to command description) */
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CommandSelectorProps {
|
|
15
|
+
/** Commands to display */
|
|
16
|
+
commands: CommandItem[];
|
|
17
|
+
/** Currently selected index */
|
|
18
|
+
selectedIndex: number;
|
|
19
|
+
/** Called when selection changes */
|
|
20
|
+
onSelectionChange: (index: number) => void;
|
|
21
|
+
/** Called when a command is selected */
|
|
22
|
+
onSelect: (command: Command) => void;
|
|
23
|
+
/** Called when user wants to exit */
|
|
24
|
+
onExit: () => void;
|
|
25
|
+
/** Breadcrumb path for nested commands */
|
|
26
|
+
breadcrumb?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Command selection menu.
|
|
31
|
+
*/
|
|
32
|
+
export function CommandSelector({
|
|
33
|
+
commands,
|
|
34
|
+
selectedIndex,
|
|
35
|
+
onSelectionChange,
|
|
36
|
+
onSelect,
|
|
37
|
+
onExit,
|
|
38
|
+
breadcrumb,
|
|
39
|
+
}: CommandSelectorProps) {
|
|
40
|
+
// Keyboard handler for navigation
|
|
41
|
+
useKeyboardHandler(
|
|
42
|
+
(event) => {
|
|
43
|
+
const { key } = event;
|
|
44
|
+
|
|
45
|
+
// Arrow key navigation
|
|
46
|
+
if (key.name === "down") {
|
|
47
|
+
const newIndex = Math.min(selectedIndex + 1, commands.length - 1);
|
|
48
|
+
onSelectionChange(newIndex);
|
|
49
|
+
event.stopPropagation();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (key.name === "up") {
|
|
54
|
+
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
55
|
+
onSelectionChange(newIndex);
|
|
56
|
+
event.stopPropagation();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Enter to select command
|
|
61
|
+
if (key.name === "return" || key.name === "enter") {
|
|
62
|
+
const selected = commands[selectedIndex];
|
|
63
|
+
if (selected) {
|
|
64
|
+
onSelect(selected.command);
|
|
65
|
+
}
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Escape to exit or go back
|
|
71
|
+
if (key.name === "escape") {
|
|
72
|
+
onExit();
|
|
73
|
+
event.stopPropagation();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
KeyboardPriority.Focused
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const title = breadcrumb?.length
|
|
81
|
+
? `Select Command (${breadcrumb.join(" › ")})`
|
|
82
|
+
: "Select Command";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<box
|
|
86
|
+
flexDirection="column"
|
|
87
|
+
flexGrow={1}
|
|
88
|
+
justifyContent="center"
|
|
89
|
+
alignItems="center"
|
|
90
|
+
gap={1}
|
|
91
|
+
>
|
|
92
|
+
<box
|
|
93
|
+
flexDirection="column"
|
|
94
|
+
border={true}
|
|
95
|
+
borderStyle="rounded"
|
|
96
|
+
borderColor={Theme.borderFocused}
|
|
97
|
+
title={title}
|
|
98
|
+
paddingLeft={3}
|
|
99
|
+
paddingRight={3}
|
|
100
|
+
paddingTop={1}
|
|
101
|
+
paddingBottom={1}
|
|
102
|
+
minWidth={60}
|
|
103
|
+
>
|
|
104
|
+
<box flexDirection="column" gap={1}>
|
|
105
|
+
{commands.map((item, idx) => {
|
|
106
|
+
const isSelected = idx === selectedIndex;
|
|
107
|
+
const prefix = isSelected ? "► " : " ";
|
|
108
|
+
const label = item.label ?? item.command.displayName ?? item.command.name;
|
|
109
|
+
const description = item.description ?? item.command.description;
|
|
110
|
+
|
|
111
|
+
// Show mode indicators
|
|
112
|
+
const modeIndicator = getModeIndicator(item.command);
|
|
113
|
+
|
|
114
|
+
if (isSelected) {
|
|
115
|
+
return (
|
|
116
|
+
<box key={item.command.name} flexDirection="column">
|
|
117
|
+
<text fg="#000000" bg="cyan">
|
|
118
|
+
{prefix}{label} {modeIndicator}
|
|
119
|
+
</text>
|
|
120
|
+
<text fg={Theme.label}>
|
|
121
|
+
{" "}{description}
|
|
122
|
+
</text>
|
|
123
|
+
</box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<box key={item.command.name} flexDirection="column">
|
|
129
|
+
<text fg={Theme.value}>
|
|
130
|
+
{prefix}{label} {modeIndicator}
|
|
131
|
+
</text>
|
|
132
|
+
<text fg={Theme.border}>
|
|
133
|
+
{" "}{description}
|
|
134
|
+
</text>
|
|
135
|
+
</box>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</box>
|
|
139
|
+
</box>
|
|
140
|
+
|
|
141
|
+
<text fg={Theme.label}>
|
|
142
|
+
↑↓ Navigate • Enter Select • Esc {breadcrumb?.length ? "Back" : "Exit"}
|
|
143
|
+
</text>
|
|
144
|
+
</box>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get mode indicator for a command (e.g., "[cli]", "[tui]", "[cli/tui]").
|
|
150
|
+
*/
|
|
151
|
+
function getModeIndicator(command: Command): string {
|
|
152
|
+
const cli = command.supportsCli();
|
|
153
|
+
const tui = command.supportsTui();
|
|
154
|
+
|
|
155
|
+
if (cli && tui) return "";
|
|
156
|
+
if (cli) return "[cli]";
|
|
157
|
+
if (tui) return "[tui]";
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { Theme } from "../theme.ts";
|
|
4
|
+
import { FieldRow } from "./FieldRow.tsx";
|
|
5
|
+
import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
|
|
6
|
+
import type { FieldConfig } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
interface ConfigFormProps {
|
|
9
|
+
/** Title for the form border */
|
|
10
|
+
title: string;
|
|
11
|
+
/** Field configurations */
|
|
12
|
+
fieldConfigs: FieldConfig[];
|
|
13
|
+
/** Current values */
|
|
14
|
+
values: Record<string, unknown>;
|
|
15
|
+
/** Currently selected index */
|
|
16
|
+
selectedIndex: number;
|
|
17
|
+
/** Whether the form is focused */
|
|
18
|
+
focused: boolean;
|
|
19
|
+
/** Called when selection changes */
|
|
20
|
+
onSelectionChange: (index: number) => void;
|
|
21
|
+
/** Called when a field should be edited */
|
|
22
|
+
onEditField: (fieldKey: string) => void;
|
|
23
|
+
/** Called when the action button is pressed */
|
|
24
|
+
onAction: () => void;
|
|
25
|
+
/** Function to get display value for a field */
|
|
26
|
+
getDisplayValue?: (key: string, value: unknown, type: string) => string;
|
|
27
|
+
/** The action button component */
|
|
28
|
+
actionButton: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default display value formatter.
|
|
33
|
+
*/
|
|
34
|
+
function defaultGetDisplayValue(_key: string, value: unknown, type: string): string {
|
|
35
|
+
if (type === "boolean") {
|
|
36
|
+
return value ? "True" : "False";
|
|
37
|
+
}
|
|
38
|
+
const strValue = String(value ?? "");
|
|
39
|
+
if (strValue === "") {
|
|
40
|
+
return "(empty)";
|
|
41
|
+
}
|
|
42
|
+
return strValue.length > 60 ? strValue.substring(0, 57) + "..." : strValue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generic config form that renders fields from a schema.
|
|
47
|
+
*/
|
|
48
|
+
export function ConfigForm({
|
|
49
|
+
title,
|
|
50
|
+
fieldConfigs,
|
|
51
|
+
values,
|
|
52
|
+
selectedIndex,
|
|
53
|
+
focused,
|
|
54
|
+
onSelectionChange,
|
|
55
|
+
onEditField,
|
|
56
|
+
onAction,
|
|
57
|
+
getDisplayValue = defaultGetDisplayValue,
|
|
58
|
+
actionButton,
|
|
59
|
+
}: ConfigFormProps) {
|
|
60
|
+
const borderColor = focused ? Theme.borderFocused : Theme.border;
|
|
61
|
+
const scrollboxRef = useRef<ScrollBoxRenderable>(null);
|
|
62
|
+
const totalFields = fieldConfigs.length + 1; // +1 for action button
|
|
63
|
+
|
|
64
|
+
// Auto-scroll to keep selected item visible
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (scrollboxRef.current) {
|
|
67
|
+
scrollboxRef.current.scrollTo(selectedIndex);
|
|
68
|
+
}
|
|
69
|
+
}, [selectedIndex]);
|
|
70
|
+
|
|
71
|
+
// Handle keyboard events at Focused priority (only when focused)
|
|
72
|
+
useKeyboardHandler(
|
|
73
|
+
(event) => {
|
|
74
|
+
const { key } = event;
|
|
75
|
+
|
|
76
|
+
// Arrow key navigation
|
|
77
|
+
if (key.name === "down") {
|
|
78
|
+
const newIndex = Math.min(selectedIndex + 1, totalFields - 1);
|
|
79
|
+
onSelectionChange(newIndex);
|
|
80
|
+
event.stopPropagation();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (key.name === "up") {
|
|
85
|
+
const newIndex = Math.max(selectedIndex - 1, 0);
|
|
86
|
+
onSelectionChange(newIndex);
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Enter to edit field or run action
|
|
92
|
+
if (key.name === "return" || key.name === "enter") {
|
|
93
|
+
if (selectedIndex === fieldConfigs.length) {
|
|
94
|
+
onAction();
|
|
95
|
+
} else {
|
|
96
|
+
const fieldConfig = fieldConfigs[selectedIndex];
|
|
97
|
+
if (fieldConfig) {
|
|
98
|
+
onEditField(fieldConfig.key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
event.stopPropagation();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
KeyboardPriority.Focused,
|
|
106
|
+
{ enabled: focused }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<box
|
|
111
|
+
flexDirection="column"
|
|
112
|
+
border={true}
|
|
113
|
+
borderStyle="rounded"
|
|
114
|
+
borderColor={borderColor}
|
|
115
|
+
title={title}
|
|
116
|
+
flexGrow={1}
|
|
117
|
+
padding={1}
|
|
118
|
+
>
|
|
119
|
+
<scrollbox
|
|
120
|
+
ref={scrollboxRef}
|
|
121
|
+
scrollY={true}
|
|
122
|
+
flexGrow={1}
|
|
123
|
+
>
|
|
124
|
+
<box flexDirection="column" gap={0}>
|
|
125
|
+
{fieldConfigs.map((field, idx) => {
|
|
126
|
+
const isSelected = idx === selectedIndex;
|
|
127
|
+
const displayValue = getDisplayValue(
|
|
128
|
+
field.key,
|
|
129
|
+
values[field.key],
|
|
130
|
+
field.type
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<FieldRow
|
|
135
|
+
key={field.key}
|
|
136
|
+
label={field.label}
|
|
137
|
+
value={displayValue}
|
|
138
|
+
isSelected={isSelected}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
|
|
143
|
+
{actionButton}
|
|
144
|
+
</box>
|
|
145
|
+
</scrollbox>
|
|
146
|
+
</box>
|
|
147
|
+
);
|
|
148
|
+
}
|