@pablozaiden/terminatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.devcontainer/devcontainer.json +19 -0
  2. package/.devcontainer/install-prerequisites.sh +49 -0
  3. package/.github/workflows/copilot-setup-steps.yml +32 -0
  4. package/.github/workflows/pull-request.yml +27 -0
  5. package/.github/workflows/release-npm-package.yml +78 -0
  6. package/LICENSE +21 -0
  7. package/README.md +524 -0
  8. package/examples/tui-app/commands/greet.ts +75 -0
  9. package/examples/tui-app/commands/index.ts +3 -0
  10. package/examples/tui-app/commands/math.ts +114 -0
  11. package/examples/tui-app/commands/status.ts +75 -0
  12. package/examples/tui-app/index.ts +34 -0
  13. package/guides/01-hello-world.md +96 -0
  14. package/guides/02-adding-options.md +103 -0
  15. package/guides/03-multiple-commands.md +163 -0
  16. package/guides/04-subcommands.md +206 -0
  17. package/guides/05-interactive-tui.md +194 -0
  18. package/guides/06-config-validation.md +264 -0
  19. package/guides/07-async-cancellation.md +388 -0
  20. package/guides/08-complete-application.md +673 -0
  21. package/guides/README.md +74 -0
  22. package/package.json +32 -0
  23. package/src/__tests__/application.test.ts +425 -0
  24. package/src/__tests__/buildCliCommand.test.ts +125 -0
  25. package/src/__tests__/builtins.test.ts +133 -0
  26. package/src/__tests__/colors.test.ts +127 -0
  27. package/src/__tests__/command.test.ts +157 -0
  28. package/src/__tests__/commandClass.test.ts +130 -0
  29. package/src/__tests__/context.test.ts +97 -0
  30. package/src/__tests__/help.test.ts +412 -0
  31. package/src/__tests__/parser.test.ts +268 -0
  32. package/src/__tests__/registry.test.ts +195 -0
  33. package/src/__tests__/registryNew.test.ts +160 -0
  34. package/src/__tests__/schemaToFields.test.ts +176 -0
  35. package/src/__tests__/table.test.ts +146 -0
  36. package/src/__tests__/tui.test.ts +26 -0
  37. package/src/builtins/help.ts +85 -0
  38. package/src/builtins/index.ts +4 -0
  39. package/src/builtins/settings.ts +106 -0
  40. package/src/builtins/version.ts +72 -0
  41. package/src/cli/help.ts +174 -0
  42. package/src/cli/index.ts +3 -0
  43. package/src/cli/output/colors.ts +74 -0
  44. package/src/cli/output/index.ts +2 -0
  45. package/src/cli/output/table.ts +141 -0
  46. package/src/cli/parser.ts +241 -0
  47. package/src/commands/help.ts +50 -0
  48. package/src/commands/index.ts +1 -0
  49. package/src/components/index.ts +147 -0
  50. package/src/core/application.ts +461 -0
  51. package/src/core/command.ts +269 -0
  52. package/src/core/context.ts +112 -0
  53. package/src/core/help.ts +214 -0
  54. package/src/core/index.ts +15 -0
  55. package/src/core/logger.ts +164 -0
  56. package/src/core/registry.ts +140 -0
  57. package/src/hooks/index.ts +131 -0
  58. package/src/index.ts +137 -0
  59. package/src/registry/commandRegistry.ts +77 -0
  60. package/src/registry/index.ts +1 -0
  61. package/src/tui/TuiApp.tsx +582 -0
  62. package/src/tui/TuiApplication.tsx +230 -0
  63. package/src/tui/app.ts +29 -0
  64. package/src/tui/components/ActionButton.tsx +36 -0
  65. package/src/tui/components/CliModal.tsx +81 -0
  66. package/src/tui/components/CommandSelector.tsx +159 -0
  67. package/src/tui/components/ConfigForm.tsx +148 -0
  68. package/src/tui/components/EditorModal.tsx +177 -0
  69. package/src/tui/components/FieldRow.tsx +30 -0
  70. package/src/tui/components/Header.tsx +31 -0
  71. package/src/tui/components/JsonHighlight.tsx +128 -0
  72. package/src/tui/components/LogsPanel.tsx +86 -0
  73. package/src/tui/components/ResultsPanel.tsx +93 -0
  74. package/src/tui/components/StatusBar.tsx +59 -0
  75. package/src/tui/components/index.ts +13 -0
  76. package/src/tui/components/types.ts +30 -0
  77. package/src/tui/context/KeyboardContext.tsx +118 -0
  78. package/src/tui/context/index.ts +7 -0
  79. package/src/tui/hooks/index.ts +35 -0
  80. package/src/tui/hooks/useClipboard.ts +66 -0
  81. package/src/tui/hooks/useCommandExecutor.ts +131 -0
  82. package/src/tui/hooks/useConfigState.ts +171 -0
  83. package/src/tui/hooks/useKeyboardHandler.ts +91 -0
  84. package/src/tui/hooks/useLogStream.ts +96 -0
  85. package/src/tui/hooks/useSpinner.ts +46 -0
  86. package/src/tui/index.ts +65 -0
  87. package/src/tui/theme.ts +21 -0
  88. package/src/tui/utils/buildCliCommand.ts +90 -0
  89. package/src/tui/utils/index.ts +13 -0
  90. package/src/tui/utils/parameterPersistence.ts +96 -0
  91. package/src/tui/utils/schemaToFields.ts +144 -0
  92. package/src/types/command.ts +103 -0
  93. package/src/types/execution.ts +11 -0
  94. package/src/types/index.ts +1 -0
  95. package/tsconfig.json +25 -0
@@ -0,0 +1,582 @@
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { KeyboardProvider } from "./context/index.ts";
3
+ import {
4
+ Header,
5
+ StatusBar,
6
+ CommandSelector,
7
+ ConfigForm,
8
+ EditorModal,
9
+ CliModal,
10
+ LogsPanel,
11
+ ResultsPanel,
12
+ ActionButton,
13
+ } from "./components/index.ts";
14
+ import {
15
+ useKeyboardHandler,
16
+ KeyboardPriority,
17
+ useClipboard,
18
+ useLogStream,
19
+ useCommandExecutor,
20
+ type LogSource,
21
+ } from "./hooks/index.ts";
22
+ import { schemaToFieldConfigs, getFieldDisplayValue, buildCliCommand, loadPersistedParameters, savePersistedParameters } from "./utils/index.ts";
23
+ import type { AnyCommand } from "../core/command.ts";
24
+ import type { AppContext } from "../core/context.ts";
25
+ import type { OptionValues, OptionSchema, OptionDef } from "../types/command.ts";
26
+ import type { CustomField } from "./TuiApplication.tsx";
27
+
28
+ /**
29
+ * TUI application mode.
30
+ */
31
+ enum Mode {
32
+ CommandSelect,
33
+ Config,
34
+ Running,
35
+ Results,
36
+ Error,
37
+ }
38
+
39
+ /**
40
+ * Focused section for keyboard navigation.
41
+ */
42
+ enum FocusedSection {
43
+ Config,
44
+ Logs,
45
+ Results,
46
+ }
47
+
48
+ interface TuiAppProps {
49
+ /** Application name (CLI name) */
50
+ name: string;
51
+ /** Display name for TUI header (human-readable) */
52
+ displayName?: string;
53
+ /** Application version */
54
+ version: string;
55
+ /** Available commands */
56
+ commands: AnyCommand[];
57
+ /** Application context */
58
+ context: AppContext;
59
+ /** Log source for log panel */
60
+ logSource?: LogSource;
61
+ /** Custom fields to add to the TUI form */
62
+ customFields?: CustomField[];
63
+ /** Called when user wants to exit */
64
+ onExit: () => void;
65
+ }
66
+
67
+ /**
68
+ * Main TUI application component.
69
+ * Wraps content with KeyboardProvider.
70
+ */
71
+ export function TuiApp(props: TuiAppProps) {
72
+ return (
73
+ <KeyboardProvider>
74
+ <TuiAppContent {...props} />
75
+ </KeyboardProvider>
76
+ );
77
+ }
78
+
79
+ function TuiAppContent({
80
+ name,
81
+ displayName,
82
+ version,
83
+ commands,
84
+ context,
85
+ logSource,
86
+ customFields,
87
+ onExit,
88
+ }: TuiAppProps) {
89
+ // State
90
+ const [mode, setMode] = useState<Mode>(Mode.CommandSelect);
91
+ const [selectedCommand, setSelectedCommand] = useState<AnyCommand | null>(null);
92
+ const [commandPath, setCommandPath] = useState<string[]>([]);
93
+ const [commandSelectorIndex, setCommandSelectorIndex] = useState(0);
94
+ const [selectedFieldIndex, setSelectedFieldIndex] = useState(0);
95
+ const [editingField, setEditingField] = useState<string | null>(null);
96
+ const [focusedSection, setFocusedSection] = useState<FocusedSection>(FocusedSection.Config);
97
+ const [logsVisible, setLogsVisible] = useState(false);
98
+ const [cliModalVisible, setCliModalVisible] = useState(false);
99
+ const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
100
+
101
+ // Hooks
102
+ const { logs, clearLogs } = useLogStream(logSource);
103
+ const { copyWithMessage, lastAction } = useClipboard();
104
+
105
+ // Command executor
106
+ const executeCommand = useCallback(async (cmd: AnyCommand, values: Record<string, unknown>, signal: AbortSignal) => {
107
+ // If the command provides buildConfig, build and validate before executing
108
+ let configOrValues: unknown = values;
109
+ if (cmd.buildConfig) {
110
+ configOrValues = await cmd.buildConfig(context, values as OptionValues<OptionSchema>);
111
+ }
112
+
113
+ return await cmd.execute(context, configOrValues as OptionValues<OptionSchema>, { signal });
114
+ }, [context]);
115
+
116
+ const { isExecuting, result, error, execute, cancel, reset: resetExecutor } = useCommandExecutor(
117
+ (cmd: unknown, values: unknown, signal: unknown) => executeCommand(cmd as AnyCommand, values as Record<string, unknown>, signal as AbortSignal)
118
+ );
119
+
120
+ // Computed values
121
+ const fieldConfigs = useMemo(() => {
122
+ if (!selectedCommand) return [];
123
+ const commandFields = schemaToFieldConfigs(selectedCommand.options);
124
+ // Merge custom fields if provided
125
+ if (customFields && customFields.length > 0) {
126
+ return [...commandFields, ...customFields];
127
+ }
128
+ return commandFields;
129
+ }, [selectedCommand, customFields]);
130
+
131
+ const cliCommand = useMemo(() => {
132
+ if (!selectedCommand) return "";
133
+ return buildCliCommand(name, commandPath, selectedCommand.options, configValues as OptionValues<OptionSchema>);
134
+ }, [name, commandPath, selectedCommand, configValues]);
135
+
136
+ const breadcrumb = useMemo(() => {
137
+ return commandPath.length > 0 ? commandPath : undefined;
138
+ }, [commandPath]);
139
+
140
+ // Initialize config values when command changes
141
+ const initializeConfigValues = useCallback((cmd: AnyCommand) => {
142
+ const defaults: Record<string, unknown> = {};
143
+ const optionDefs = cmd.options as OptionSchema;
144
+ for (const [key, def] of Object.entries(optionDefs)) {
145
+ const typedDef = def as OptionDef;
146
+ if (typedDef.default !== undefined) {
147
+ defaults[key] = typedDef.default;
148
+ } else {
149
+ switch (typedDef.type) {
150
+ case "string":
151
+ defaults[key] = typedDef.enum?.[0] ?? "";
152
+ break;
153
+ case "number":
154
+ defaults[key] = typedDef.min ?? 0;
155
+ break;
156
+ case "boolean":
157
+ defaults[key] = false;
158
+ break;
159
+ case "array":
160
+ defaults[key] = [];
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ // Initialize custom field defaults
166
+ if (customFields) {
167
+ for (const field of customFields) {
168
+ if (field.default !== undefined) {
169
+ defaults[field.key] = field.default;
170
+ }
171
+ }
172
+ }
173
+
174
+ // Load persisted parameters and merge with defaults
175
+ const persisted = loadPersistedParameters(name, cmd.name);
176
+ const merged = { ...defaults, ...persisted };
177
+
178
+ setConfigValues(merged);
179
+ }, [customFields, name]);
180
+
181
+ // Handlers
182
+ const handleCommandSelect = useCallback((cmd: AnyCommand) => {
183
+ // Check if command has subcommands to navigate into
184
+ if (cmd.subCommands && cmd.subCommands.length > 0 && !cmd.supportsTui() && !cmd.supportsCli()) {
185
+ // Navigate into subcommands
186
+ setCommandPath((prev) => [...prev, cmd.name]);
187
+ setCommandSelectorIndex(0);
188
+ return;
189
+ }
190
+
191
+ setSelectedCommand(cmd);
192
+ setCommandPath((prev) => [...prev, cmd.name]);
193
+ initializeConfigValues(cmd);
194
+ setSelectedFieldIndex(0);
195
+ setFocusedSection(FocusedSection.Config);
196
+ setLogsVisible(false);
197
+
198
+ // Check if command should execute immediately
199
+ if (cmd.immediateExecution) {
200
+ handleRunCommand(cmd);
201
+ } else {
202
+ setMode(Mode.Config);
203
+ }
204
+ }, [initializeConfigValues]);
205
+
206
+ const handleBack = useCallback(() => {
207
+ if (mode === Mode.Running) {
208
+ // Cancel the running command and go back
209
+ cancel();
210
+ // If command was immediate execution, go back to command select
211
+ if (selectedCommand?.immediateExecution) {
212
+ setMode(Mode.CommandSelect);
213
+ setSelectedCommand(null);
214
+ setCommandPath((prev) => prev.slice(0, -1));
215
+ setSelectedFieldIndex(0);
216
+ setFocusedSection(FocusedSection.Config);
217
+ setLogsVisible(false);
218
+ } else {
219
+ setMode(Mode.Config);
220
+ setFocusedSection(FocusedSection.Config);
221
+ }
222
+ resetExecutor();
223
+ } else if (mode === Mode.Config) {
224
+ setMode(Mode.CommandSelect);
225
+ setSelectedCommand(null);
226
+ setCommandPath((prev) => prev.slice(0, -1));
227
+ setSelectedFieldIndex(0);
228
+ setFocusedSection(FocusedSection.Config);
229
+ setLogsVisible(false);
230
+ } else if (mode === Mode.Results || mode === Mode.Error) {
231
+ // If command was immediate execution, go back to command select
232
+ if (selectedCommand?.immediateExecution) {
233
+ setMode(Mode.CommandSelect);
234
+ setSelectedCommand(null);
235
+ setCommandPath((prev) => prev.slice(0, -1));
236
+ setSelectedFieldIndex(0);
237
+ setFocusedSection(FocusedSection.Config);
238
+ setLogsVisible(false);
239
+ } else {
240
+ setMode(Mode.Config);
241
+ setFocusedSection(FocusedSection.Config);
242
+ }
243
+ resetExecutor();
244
+ } else if (mode === Mode.CommandSelect && commandPath.length > 0) {
245
+ setCommandPath((prev) => prev.slice(0, -1));
246
+ setCommandSelectorIndex(0);
247
+ } else {
248
+ onExit();
249
+ }
250
+ }, [mode, commandPath, selectedCommand, cancel, onExit, resetExecutor]);
251
+
252
+ const handleRunCommand = useCallback(async (cmd?: AnyCommand) => {
253
+ const cmdToRun = cmd ?? selectedCommand;
254
+ if (!cmdToRun) return;
255
+
256
+ // Save parameters before running
257
+ savePersistedParameters(name, cmdToRun.name, configValues);
258
+
259
+ // Set up for running
260
+ setMode(Mode.Running);
261
+ clearLogs();
262
+ setLogsVisible(true);
263
+ setFocusedSection(FocusedSection.Logs);
264
+
265
+ // Execute and wait for result
266
+ const outcome = await execute(cmdToRun, configValues);
267
+
268
+ // If cancelled, don't transition - handleBack already handled it
269
+ if (outcome.cancelled) {
270
+ return;
271
+ }
272
+
273
+ // Transition based on outcome
274
+ if (outcome.success) {
275
+ setMode(Mode.Results);
276
+ } else {
277
+ setMode(Mode.Error);
278
+ }
279
+ setFocusedSection(FocusedSection.Results);
280
+ }, [selectedCommand, configValues, clearLogs, execute, name]);
281
+
282
+ const handleEditField = useCallback((fieldKey: string) => {
283
+ setEditingField(fieldKey);
284
+ }, []);
285
+
286
+ const handleFieldSubmit = useCallback((value: unknown) => {
287
+ if (editingField) {
288
+ setConfigValues((prev) => {
289
+ let newValues = { ...prev, [editingField]: value };
290
+
291
+ // Call command's onConfigChange if available
292
+ if (selectedCommand?.onConfigChange) {
293
+ const updates = selectedCommand.onConfigChange(editingField, value, newValues);
294
+ if (updates) {
295
+ newValues = { ...newValues, ...updates };
296
+ }
297
+ }
298
+
299
+ // Call custom field onChange if applicable
300
+ const customField = customFields?.find((f) => f.key === editingField);
301
+ if (customField?.onChange) {
302
+ customField.onChange(value, newValues);
303
+ }
304
+ return newValues;
305
+ });
306
+ }
307
+ setEditingField(null);
308
+ }, [editingField, customFields, selectedCommand]);
309
+
310
+ const handleCopy = useCallback((content: string, label: string) => {
311
+ copyWithMessage(content, label);
312
+ }, [copyWithMessage]);
313
+
314
+ /**
315
+ * Get clipboard content based on current mode and focused section.
316
+ * Returns { content, label } or null if nothing to copy.
317
+ */
318
+ const getClipboardContent = useCallback((): { content: string; label: string } | null => {
319
+ // In Running mode or when logs are focused, copy logs
320
+ if (mode === Mode.Running || (logsVisible && focusedSection === FocusedSection.Logs)) {
321
+ if (logs.length > 0) {
322
+ const content = logs
323
+ .map((log) => `[${log.level}] ${log.timestamp.toISOString()} ${log.message}`)
324
+ .join("\n");
325
+ return { content, label: "Logs" };
326
+ }
327
+ return null;
328
+ }
329
+
330
+ // In Results/Error mode with results focused
331
+ if ((mode === Mode.Results || mode === Mode.Error) && focusedSection === FocusedSection.Results) {
332
+ if (error) {
333
+ return { content: error.message, label: "Error" };
334
+ }
335
+ if (result) {
336
+ // Use command's getClipboardContent if available
337
+ if (selectedCommand?.getClipboardContent) {
338
+ const customContent = selectedCommand.getClipboardContent(result);
339
+ if (customContent) {
340
+ return { content: customContent, label: "Results" };
341
+ }
342
+ }
343
+ return { content: JSON.stringify(result.data ?? result, null, 2), label: "Results" };
344
+ }
345
+ return null;
346
+ }
347
+
348
+ // In Config mode with config focused, copy config JSON
349
+ if (mode === Mode.Config && focusedSection === FocusedSection.Config) {
350
+ return { content: JSON.stringify(configValues, null, 2), label: "Config" };
351
+ }
352
+
353
+ return null;
354
+ }, [mode, focusedSection, logsVisible, logs, error, result, configValues, selectedCommand]);
355
+
356
+ const cycleFocusedSection = useCallback(() => {
357
+ const sections: FocusedSection[] = [];
358
+ if (mode === Mode.Config) sections.push(FocusedSection.Config);
359
+ if (mode === Mode.Results || mode === Mode.Error) sections.push(FocusedSection.Results);
360
+ if (logsVisible) sections.push(FocusedSection.Logs);
361
+
362
+ if (sections.length <= 1) return;
363
+
364
+ const currentIdx = sections.indexOf(focusedSection);
365
+ const nextIdx = (currentIdx + 1) % sections.length;
366
+ setFocusedSection(sections[nextIdx]!);
367
+ }, [mode, logsVisible, focusedSection]);
368
+
369
+ // Global keyboard handler
370
+ useKeyboardHandler(
371
+ (event) => {
372
+ const { key } = event;
373
+
374
+ // Escape to go back
375
+ if (key.name === "escape") {
376
+ handleBack();
377
+ event.stopPropagation();
378
+ return;
379
+ }
380
+
381
+ // Y to copy content based on current mode and focus
382
+ if ((key.name === "y")) {
383
+ const clipboardData = getClipboardContent();
384
+ if (clipboardData) {
385
+ handleCopy(clipboardData.content, clipboardData.label);
386
+ }
387
+ event.stopPropagation();
388
+ return;
389
+ }
390
+
391
+ // Tab to cycle focus
392
+ if (key.name === "tab") {
393
+ cycleFocusedSection();
394
+ event.stopPropagation();
395
+ return;
396
+ }
397
+
398
+ // L to toggle logs
399
+ if (key.name === "l" && !editingField) {
400
+ setLogsVisible((prev) => !prev);
401
+ event.stopPropagation();
402
+ return;
403
+ }
404
+
405
+ // C to show CLI command
406
+ if (key.name === "c" && !editingField && mode === Mode.Config) {
407
+ setCliModalVisible(true);
408
+ event.stopPropagation();
409
+ return;
410
+ }
411
+ },
412
+ KeyboardPriority.Global,
413
+ { enabled: !editingField && !cliModalVisible }
414
+ );
415
+
416
+ // Get current commands for selector
417
+ const currentCommands = useMemo(() => {
418
+ if (commandPath.length === 0) {
419
+ return commands;
420
+ }
421
+
422
+ // Navigate to current path
423
+ let current: AnyCommand[] = commands;
424
+ for (const pathPart of commandPath.slice(0, -1)) {
425
+ const found = current.find((c) => c.name === pathPart);
426
+ if (found?.subCommands) {
427
+ current = found.subCommands;
428
+ }
429
+ }
430
+ return current;
431
+ }, [commands, commandPath]);
432
+
433
+ // Status message
434
+ const statusMessage = useMemo(() => {
435
+ if (lastAction) return lastAction;
436
+ if (isExecuting) return "Running benchmark...";
437
+ if (mode === Mode.Error) return "Error occurred. Press Esc to go back.";
438
+ if (mode === Mode.Results) return "Run completed. Press Esc to return to config.";
439
+ if (mode === Mode.CommandSelect) return "Select a command to get started.";
440
+ if (mode === Mode.Config) {
441
+ return `Ready. Select [${selectedCommand?.actionLabel ?? "Run"}] and press Enter.`;
442
+ }
443
+ return "";
444
+ }, [lastAction, isExecuting, mode, selectedCommand]);
445
+
446
+ const shortcuts = useMemo(() => {
447
+ const parts: string[] = [];
448
+ if (mode === Mode.Config) {
449
+ parts.push("↑↓ Navigate", "Enter Edit", "Y Copy", "C CLI", "L Logs", "Esc Back");
450
+ } else if (mode === Mode.Running) {
451
+ parts.push("Y Copy", "Esc Cancel");
452
+ } else if (mode === Mode.Results || mode === Mode.Error) {
453
+ parts.push("Tab Focus", "Y Copy", "Esc Back");
454
+ } else {
455
+ parts.push("↑↓ Navigate", "Enter Select", "Esc Exit");
456
+ }
457
+ return parts.join(" • ");
458
+ }, [mode]);
459
+
460
+ // Get display value for fields
461
+ const getDisplayValue = useCallback((key: string, value: unknown, _type: string) => {
462
+ const fieldConfig = fieldConfigs.find((f) => f.key === key);
463
+ if (fieldConfig) {
464
+ return getFieldDisplayValue(value, fieldConfig);
465
+ }
466
+ return String(value ?? "");
467
+ }, [fieldConfigs]);
468
+
469
+ // Render the main content based on current mode
470
+ const renderContent = () => {
471
+ switch (mode) {
472
+ case Mode.CommandSelect:
473
+ return (
474
+ <CommandSelector
475
+ commands={currentCommands.map((cmd) => ({ command: cmd }))}
476
+ selectedIndex={commandSelectorIndex}
477
+ onSelectionChange={setCommandSelectorIndex}
478
+ onSelect={handleCommandSelect}
479
+ onExit={handleBack}
480
+ breadcrumb={commandPath.length > 0 ? commandPath : undefined}
481
+ />
482
+ );
483
+
484
+ case Mode.Config:
485
+ if (!selectedCommand) return null;
486
+ return (
487
+ <box flexDirection="column" flexGrow={1}>
488
+ <ConfigForm
489
+ title={`Configure: ${selectedCommand.name}`}
490
+ fieldConfigs={fieldConfigs}
491
+ values={configValues}
492
+ selectedIndex={selectedFieldIndex}
493
+ focused={focusedSection === FocusedSection.Config}
494
+ onSelectionChange={setSelectedFieldIndex}
495
+ onEditField={handleEditField}
496
+ onAction={() => handleRunCommand()}
497
+ getDisplayValue={getDisplayValue}
498
+ actionButton={
499
+ <ActionButton
500
+ label={selectedCommand.actionLabel ?? "Run"}
501
+ isSelected={selectedFieldIndex === fieldConfigs.length}
502
+ />
503
+ }
504
+ />
505
+ {logsVisible && (
506
+ <LogsPanel
507
+ logs={logs}
508
+ visible={true}
509
+ focused={focusedSection === FocusedSection.Logs}
510
+ />
511
+ )}
512
+ </box>
513
+ );
514
+
515
+ case Mode.Running:
516
+ return (
517
+ <LogsPanel
518
+ logs={logs}
519
+ visible={true}
520
+ focused={true}
521
+ expanded={true}
522
+ />
523
+ );
524
+
525
+ case Mode.Results:
526
+ case Mode.Error:
527
+ return (
528
+ <box flexDirection="column" flexGrow={1} gap={1}>
529
+ <ResultsPanel
530
+ result={result}
531
+ error={error}
532
+ focused={focusedSection === FocusedSection.Results}
533
+ renderResult={selectedCommand?.renderResult}
534
+ />
535
+ {logsVisible && (
536
+ <LogsPanel
537
+ logs={logs}
538
+ visible={true}
539
+ focused={focusedSection === FocusedSection.Logs}
540
+ />
541
+ )}
542
+ </box>
543
+ );
544
+
545
+ default:
546
+ return null;
547
+ }
548
+ };
549
+
550
+ return (
551
+ <box flexDirection="column" flexGrow={1} padding={1}>
552
+ <Header name={displayName ?? name} version={version} breadcrumb={breadcrumb} />
553
+
554
+ <box key={`content-${mode}-${isExecuting}`} flexDirection="column" flexGrow={1}>
555
+ {renderContent()}
556
+ </box>
557
+
558
+ <StatusBar
559
+ status={statusMessage}
560
+ isRunning={isExecuting}
561
+ shortcuts={shortcuts}
562
+ />
563
+
564
+ {/* Modals */}
565
+ <EditorModal
566
+ fieldKey={editingField}
567
+ currentValue={editingField ? configValues[editingField] : undefined}
568
+ visible={editingField !== null}
569
+ onSubmit={handleFieldSubmit}
570
+ onCancel={() => setEditingField(null)}
571
+ fieldConfigs={fieldConfigs}
572
+ />
573
+
574
+ <CliModal
575
+ command={cliCommand}
576
+ visible={cliModalVisible}
577
+ onClose={() => setCliModalVisible(false)}
578
+ onCopy={handleCopy}
579
+ />
580
+ </box>
581
+ );
582
+ }