@pablozaiden/terminatui 0.3.0 → 0.4.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.
- package/package.json +1 -1
- package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
- package/src/__tests__/schemaToFields.test.ts +0 -4
- package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
- package/src/index.ts +2 -2
- package/src/tui/TuiApplication.tsx +0 -4
- package/src/tui/TuiRoot.tsx +58 -102
- package/src/tui/actions.ts +4 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +191 -45
- package/src/tui/adapters/ink/SemanticInkRenderer.tsx +210 -0
- package/src/tui/adapters/ink/components/Button.tsx +10 -2
- package/src/tui/adapters/ink/components/Overlay.tsx +8 -2
- package/src/tui/adapters/ink/components/Panel.tsx +26 -5
- package/src/tui/adapters/ink/components/ScrollView.tsx +44 -3
- package/src/tui/adapters/ink/components/Spinner.tsx +8 -2
- package/src/tui/adapters/ink/keyboard.ts +0 -3
- package/src/tui/adapters/ink/ui/CommandSelector.tsx +56 -0
- package/src/tui/adapters/ink/ui/ConfigForm.tsx +77 -0
- package/src/tui/adapters/ink/ui/Header.tsx +25 -0
- package/src/tui/adapters/ink/ui/JsonHighlight.tsx +21 -0
- package/src/tui/adapters/ink/ui/ResultsPanel.tsx +57 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +190 -43
- package/src/tui/adapters/opentui/SemanticOpenTuiRenderer.tsx +192 -0
- package/src/tui/adapters/opentui/components/Label.tsx +2 -2
- package/src/tui/adapters/opentui/components/Overlay.tsx +12 -3
- package/src/tui/adapters/opentui/components/Panel.tsx +11 -1
- package/src/tui/adapters/opentui/components/ScrollView.tsx +1 -8
- package/src/tui/adapters/opentui/components/Spinner.tsx +1 -1
- package/src/tui/adapters/opentui/keyboard.ts +0 -3
- package/src/tui/adapters/opentui/ui/CommandSelector.tsx +55 -0
- package/src/tui/adapters/opentui/ui/ConfigForm.tsx +74 -0
- package/src/tui/adapters/opentui/ui/Header.tsx +24 -0
- package/src/tui/adapters/opentui/ui/JsonHighlight.tsx +20 -0
- package/src/tui/adapters/opentui/ui/LogsPanel.tsx +44 -0
- package/src/tui/adapters/opentui/ui/ResultsPanel.tsx +62 -0
- package/src/tui/adapters/shared/TerminalClipboard.ts +65 -0
- package/src/tui/adapters/{opentui/hooks → shared}/useSpinner.ts +5 -1
- package/src/tui/adapters/types.ts +25 -46
- package/src/tui/components/JsonHighlight.tsx +41 -111
- package/src/tui/context/ActionContext.tsx +51 -0
- package/src/tui/context/ExecutorContext.tsx +7 -1
- package/src/tui/context/NavigationContext.tsx +20 -4
- package/src/tui/controllers/CommandBrowserController.tsx +100 -0
- package/src/tui/controllers/ConfigController.tsx +183 -0
- package/src/tui/controllers/EditorController.tsx +169 -0
- package/src/tui/controllers/LogsController.tsx +48 -0
- package/src/tui/controllers/OutcomeController.tsx +110 -0
- package/src/tui/driver/TuiDriver.tsx +148 -0
- package/src/tui/driver/context/TuiDriverContext.tsx +44 -0
- package/src/tui/driver/types.ts +72 -0
- package/src/tui/semantic/AppShell.tsx +30 -0
- package/src/tui/semantic/CommandBrowserScreen.tsx +16 -0
- package/src/tui/semantic/ConfigScreen.tsx +23 -0
- package/src/tui/semantic/EditorScreen.tsx +20 -0
- package/src/tui/semantic/LogsScreen.tsx +9 -0
- package/src/tui/semantic/RunningScreen.tsx +17 -0
- package/src/tui/semantic/layoutTypes.ts +72 -0
- package/src/tui/semantic/render.tsx +44 -0
- package/src/tui/semantic/types.ts +31 -98
- package/src/tui/utils/jsonTokenizer.ts +98 -0
- package/src/tui/utils/schemaToFields.ts +1 -25
- package/src/tui/adapters/ink/components/Code.tsx +0 -6
- package/src/tui/adapters/ink/components/Container.tsx +0 -5
- package/src/tui/adapters/ink/components/Spacer.tsx +0 -15
- package/src/tui/adapters/ink/components/Value.tsx +0 -7
- package/src/tui/adapters/opentui/components/Code.tsx +0 -12
- package/src/tui/adapters/opentui/components/Container.tsx +0 -56
- package/src/tui/adapters/opentui/components/Spacer.tsx +0 -5
- package/src/tui/adapters/opentui/components/Value.tsx +0 -13
- package/src/tui/components/ActionButton.tsx +0 -0
- package/src/tui/components/CommandSelector.tsx +0 -119
- package/src/tui/components/ConfigForm.tsx +0 -174
- package/src/tui/components/FieldRow.tsx +0 -0
- package/src/tui/components/Header.tsx +0 -32
- package/src/tui/components/ModalBase.tsx +0 -38
- package/src/tui/components/ResultsPanel.tsx +0 -84
- package/src/tui/components/StatusBar.tsx +0 -44
- package/src/tui/components/logColors.ts +0 -12
- package/src/tui/components/types.ts +0 -30
- package/src/tui/context/ClipboardContext.tsx +0 -87
- package/src/tui/context/KeyboardContext.tsx +0 -132
- package/src/tui/hooks/useActiveKeyHandler.ts +0 -75
- package/src/tui/hooks/useClipboard.ts +0 -81
- package/src/tui/hooks/useClipboardProvider.ts +0 -42
- package/src/tui/hooks/useGlobalKeyHandler.ts +0 -54
- package/src/tui/modals/CliModal.tsx +0 -82
- package/src/tui/modals/EditorModal.tsx +0 -207
- package/src/tui/modals/LogsModal.tsx +0 -98
- package/src/tui/registry.ts +0 -102
- package/src/tui/screens/CommandSelectScreen.tsx +0 -162
- package/src/tui/screens/ConfigScreen.tsx +0 -165
- package/src/tui/screens/ErrorScreen.tsx +0 -58
- package/src/tui/screens/ResultsScreen.tsx +0 -68
- package/src/tui/screens/RunningScreen.tsx +0 -72
- package/src/tui/screens/ScreenBase.ts +0 -6
- package/src/tui/semantic/Button.tsx +0 -7
- package/src/tui/semantic/Code.tsx +0 -7
- package/src/tui/semantic/CodeHighlight.tsx +0 -7
- package/src/tui/semantic/Container.tsx +0 -7
- package/src/tui/semantic/Field.tsx +0 -7
- package/src/tui/semantic/Label.tsx +0 -7
- package/src/tui/semantic/MenuButton.tsx +0 -7
- package/src/tui/semantic/MenuItem.tsx +0 -7
- package/src/tui/semantic/Overlay.tsx +0 -7
- package/src/tui/semantic/Panel.tsx +0 -7
- package/src/tui/semantic/ScrollView.tsx +0 -9
- package/src/tui/semantic/Select.tsx +0 -7
- package/src/tui/semantic/Spacer.tsx +0 -7
- package/src/tui/semantic/Spinner.tsx +0 -7
- package/src/tui/semantic/TextInput.tsx +0 -7
- package/src/tui/semantic/Value.tsx +0 -7
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
|
-
export interface ScreenEntry<TParams = unknown> {
|
|
12
|
-
route:
|
|
11
|
+
export interface ScreenEntry<TRoute extends string = string, TParams = unknown> {
|
|
12
|
+
route: TRoute;
|
|
13
13
|
params?: TParams;
|
|
14
14
|
meta?: { focus?: string; breadcrumb?: string[] };
|
|
15
15
|
}
|
|
@@ -38,6 +38,8 @@ export interface NavigationAPI {
|
|
|
38
38
|
currentModal?: ModalEntry;
|
|
39
39
|
openModal: <TParams>(id: string, params?: TParams) => void;
|
|
40
40
|
closeModal: () => void;
|
|
41
|
+
/** Update the topmost modal's params (for reactive state updates) */
|
|
42
|
+
updateModal: <TParams>(params: TParams) => void;
|
|
41
43
|
hasModal: boolean;
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -57,7 +59,7 @@ export interface NavigationAPI {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
type NavigationProviderProps<TParams = unknown> = {
|
|
60
|
-
initialScreen: ScreenEntry<TParams>;
|
|
62
|
+
initialScreen: ScreenEntry<string, TParams>;
|
|
61
63
|
children: ReactNode;
|
|
62
64
|
/** Called when we can't go back anymore (at root with empty stack) */
|
|
63
65
|
onExit?: () => void;
|
|
@@ -69,7 +71,8 @@ type NavigationAction =
|
|
|
69
71
|
| { type: "reset"; screen: ScreenEntry }
|
|
70
72
|
| { type: "pop" }
|
|
71
73
|
| { type: "openModal"; modal: ModalEntry }
|
|
72
|
-
| { type: "closeModal" }
|
|
74
|
+
| { type: "closeModal" }
|
|
75
|
+
| { type: "updateModal"; params: unknown };
|
|
73
76
|
|
|
74
77
|
type NavigationState = {
|
|
75
78
|
stack: ScreenEntry[];
|
|
@@ -101,6 +104,17 @@ function navigationReducer(
|
|
|
101
104
|
if (state.modalStack.length === 0) return state;
|
|
102
105
|
return { ...state, modalStack: state.modalStack.slice(0, -1) };
|
|
103
106
|
}
|
|
107
|
+
case "updateModal": {
|
|
108
|
+
if (state.modalStack.length === 0) return state;
|
|
109
|
+
const updatedModal = {
|
|
110
|
+
...state.modalStack[state.modalStack.length - 1]!,
|
|
111
|
+
params: action.params,
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
...state,
|
|
115
|
+
modalStack: [...state.modalStack.slice(0, -1), updatedModal],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
104
118
|
default:
|
|
105
119
|
return state;
|
|
106
120
|
}
|
|
@@ -172,6 +186,8 @@ export function NavigationProvider<TParams = unknown>({
|
|
|
172
186
|
openModal: <TParams,>(id: string, params?: TParams) =>
|
|
173
187
|
dispatch({ type: "openModal", modal: { id, params } }),
|
|
174
188
|
closeModal: () => dispatch({ type: "closeModal" }),
|
|
189
|
+
updateModal: <TParams,>(params: TParams) =>
|
|
190
|
+
dispatch({ type: "updateModal", params }),
|
|
175
191
|
hasModal: modalStack.length > 0,
|
|
176
192
|
goBack,
|
|
177
193
|
setBackHandler,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { AnyCommand } from "../../core/command.ts";
|
|
2
|
+
import type { NavigationAPI } from "../context/NavigationContext.tsx";
|
|
3
|
+
|
|
4
|
+
import { RenderCommandBrowserScreen } from "../semantic/render.tsx";
|
|
5
|
+
import { schemaToFieldConfigs } from "../utils/schemaToFields.ts";
|
|
6
|
+
|
|
7
|
+
import type { CommandBrowserRouteParams, TuiRoute } from "../driver/types.ts";
|
|
8
|
+
import type { ConfigController } from "./ConfigController.tsx";
|
|
9
|
+
|
|
10
|
+
export class CommandBrowserController {
|
|
11
|
+
private commands: AnyCommand[];
|
|
12
|
+
private configController: ConfigController;
|
|
13
|
+
private navigation: NavigationAPI;
|
|
14
|
+
|
|
15
|
+
private clampSelectedIndex(index: number, commands: AnyCommand[]): number {
|
|
16
|
+
return Math.max(0, Math.min(index, Math.max(0, commands.length - 1)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public constructor({
|
|
20
|
+
commands,
|
|
21
|
+
navigation,
|
|
22
|
+
configController,
|
|
23
|
+
}: {
|
|
24
|
+
commands: AnyCommand[];
|
|
25
|
+
navigation: NavigationAPI;
|
|
26
|
+
configController: ConfigController;
|
|
27
|
+
}) {
|
|
28
|
+
this.commands = commands;
|
|
29
|
+
this.navigation = navigation;
|
|
30
|
+
this.configController = configController;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public render(): { node: React.ReactNode; breadcrumb: string[] } {
|
|
34
|
+
const params = (this.navigation.current.params ?? { commandPath: [] }) as CommandBrowserRouteParams;
|
|
35
|
+
const commandPath = params.commandPath ?? [];
|
|
36
|
+
const selectedIndex = params.selectedIndex ?? 0;
|
|
37
|
+
|
|
38
|
+
const currentCommands = this.getCommandsAtPath(commandPath);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
breadcrumb: commandPath,
|
|
42
|
+
node: (
|
|
43
|
+
<RenderCommandBrowserScreen
|
|
44
|
+
commandId={commandPath}
|
|
45
|
+
commands={currentCommands}
|
|
46
|
+
selectedCommandIndex={this.clampSelectedIndex(selectedIndex, currentCommands)}
|
|
47
|
+
onOpenPath={(nextPath) => {
|
|
48
|
+
this.navigation.replace("commandBrowser" satisfies TuiRoute, { commandPath: nextPath, selectedIndex: 0 });
|
|
49
|
+
}}
|
|
50
|
+
onSelectCommand={(index) => {
|
|
51
|
+
const clampedIndex = this.clampSelectedIndex(index, currentCommands);
|
|
52
|
+
this.navigation.replace("commandBrowser" satisfies TuiRoute, { commandPath, selectedIndex: clampedIndex });
|
|
53
|
+
}}
|
|
54
|
+
onRunSelected={() => {
|
|
55
|
+
const clampedIndex = this.clampSelectedIndex(selectedIndex, currentCommands);
|
|
56
|
+
const selected = currentCommands[clampedIndex];
|
|
57
|
+
if (!selected) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If selected command has navigable subcommands, navigate to them instead of config
|
|
62
|
+
const navigableSubCommands = selected.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
|
|
63
|
+
if (navigableSubCommands.length > 0) {
|
|
64
|
+
this.navigation.replace("commandBrowser" satisfies TuiRoute, {
|
|
65
|
+
commandPath: [...commandPath, selected.name],
|
|
66
|
+
selectedIndex: 0
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.navigation.push("config" satisfies TuiRoute, {
|
|
72
|
+
command: selected,
|
|
73
|
+
commandPath,
|
|
74
|
+
values: this.configController.initializeValues(selected),
|
|
75
|
+
fieldConfigs: schemaToFieldConfigs(selected.options),
|
|
76
|
+
});
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private getCommandsAtPath(commandPath: string[]): AnyCommand[] {
|
|
84
|
+
if (commandPath.length === 0) {
|
|
85
|
+
return this.commands.filter((cmd) => cmd.supportsTui());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let current: AnyCommand[] = this.commands;
|
|
89
|
+
for (const pathPart of commandPath) {
|
|
90
|
+
const found = current.find((c) => c.name === pathPart);
|
|
91
|
+
if (found?.subCommands) {
|
|
92
|
+
current = found.subCommands.filter((sub) => sub.supportsTui());
|
|
93
|
+
} else {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { AnyCommand } from "../../core/command.ts";
|
|
2
|
+
import type { NavigationAPI } from "../context/NavigationContext.tsx";
|
|
3
|
+
import type { ExecutorContextValue } from "../context/ExecutorContext.tsx";
|
|
4
|
+
|
|
5
|
+
import { RenderConfigScreen } from "../semantic/render.tsx";
|
|
6
|
+
|
|
7
|
+
import { buildCliCommand } from "../utils/buildCliCommand.ts";
|
|
8
|
+
import { loadPersistedParameters, savePersistedParameters } from "../utils/parameterPersistence.ts";
|
|
9
|
+
|
|
10
|
+
import type { OptionDef, OptionSchema } from "../../types/command.ts";
|
|
11
|
+
import type {
|
|
12
|
+
ConfigRouteParams,
|
|
13
|
+
EditorModalParams,
|
|
14
|
+
TuiRoute,
|
|
15
|
+
} from "../driver/types.ts";
|
|
16
|
+
|
|
17
|
+
export class ConfigController {
|
|
18
|
+
private appName: string;
|
|
19
|
+
private navigation: NavigationAPI;
|
|
20
|
+
private executor: ExecutorContextValue;
|
|
21
|
+
|
|
22
|
+
public constructor({
|
|
23
|
+
appName,
|
|
24
|
+
navigation,
|
|
25
|
+
executor,
|
|
26
|
+
}: {
|
|
27
|
+
appName: string;
|
|
28
|
+
navigation: NavigationAPI;
|
|
29
|
+
executor: ExecutorContextValue;
|
|
30
|
+
}) {
|
|
31
|
+
this.appName = appName;
|
|
32
|
+
this.navigation = navigation;
|
|
33
|
+
this.executor = executor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public async run(params: ConfigRouteParams): Promise<void> {
|
|
37
|
+
savePersistedParameters(this.appName, params.command.name, params.values);
|
|
38
|
+
|
|
39
|
+
this.navigation.push("running" satisfies TuiRoute, {
|
|
40
|
+
command: params.command,
|
|
41
|
+
commandPath: params.commandPath,
|
|
42
|
+
values: params.values,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Set a back handler that cancels the execution when Esc is pressed
|
|
46
|
+
this.navigation.setBackHandler(() => {
|
|
47
|
+
this.executor.cancel();
|
|
48
|
+
return true; // We handle the back action - don't pop the stack yet
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const outcome = await this.executor.execute(params.command, params.values);
|
|
53
|
+
|
|
54
|
+
if (outcome.cancelled) {
|
|
55
|
+
this.navigation.pop();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (outcome.success) {
|
|
60
|
+
this.navigation.replace("results" satisfies TuiRoute, {
|
|
61
|
+
command: params.command,
|
|
62
|
+
commandPath: params.commandPath,
|
|
63
|
+
values: params.values,
|
|
64
|
+
result: outcome.result ?? null,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.navigation.replace("error" satisfies TuiRoute, {
|
|
70
|
+
command: params.command,
|
|
71
|
+
commandPath: params.commandPath,
|
|
72
|
+
values: params.values,
|
|
73
|
+
error: outcome.error ?? new Error("Unknown error"),
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
// Clear the back handler when execution completes
|
|
77
|
+
this.navigation.setBackHandler(null);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public getCopyPayload(params: {
|
|
82
|
+
command: AnyCommand;
|
|
83
|
+
commandPath: string[];
|
|
84
|
+
values: Record<string, unknown>;
|
|
85
|
+
}): { label: string; content: string } {
|
|
86
|
+
const schema = params.command.options as OptionSchema;
|
|
87
|
+
const cli = buildCliCommand(this.appName, params.commandPath, schema, params.values as any);
|
|
88
|
+
return { label: "CLI", content: cli };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public render(): { node: React.ReactNode; breadcrumb?: string[] } {
|
|
92
|
+
const params = this.navigation.current.params as ConfigRouteParams | undefined;
|
|
93
|
+
|
|
94
|
+
if (!params) {
|
|
95
|
+
return { node: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const title = `Configure: ${params.command.displayName ?? params.command.name}`;
|
|
99
|
+
const selectedFieldIndex = params.selectedFieldIndex ?? 0;
|
|
100
|
+
const clampedIndex = Math.min(selectedFieldIndex, Math.max(0, params.fieldConfigs.length));
|
|
101
|
+
|
|
102
|
+
const schema = params.command.options as OptionSchema;
|
|
103
|
+
const cliCommand = buildCliCommand(this.appName, params.commandPath, schema, params.values as any);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
breadcrumb: params.commandPath,
|
|
107
|
+
node: (
|
|
108
|
+
<RenderConfigScreen
|
|
109
|
+
title={title}
|
|
110
|
+
commandId={params.commandPath}
|
|
111
|
+
fieldConfigs={params.fieldConfigs}
|
|
112
|
+
values={params.values}
|
|
113
|
+
cliCommand={cliCommand}
|
|
114
|
+
selectedFieldIndex={clampedIndex}
|
|
115
|
+
onSelectionChange={(index) => {
|
|
116
|
+
const maxIndex = params.fieldConfigs.length;
|
|
117
|
+
const nextIndex = Math.max(0, Math.min(index, maxIndex));
|
|
118
|
+
this.navigation.replace("config" satisfies TuiRoute, {
|
|
119
|
+
...params,
|
|
120
|
+
selectedFieldIndex: nextIndex,
|
|
121
|
+
});
|
|
122
|
+
}}
|
|
123
|
+
onEditField={(fieldId) => {
|
|
124
|
+
const fieldValue = params.values[fieldId];
|
|
125
|
+
const fieldConfig = params.fieldConfigs.find((f) => f.key === fieldId);
|
|
126
|
+
const fieldDisplayName = fieldConfig?.label ?? fieldId;
|
|
127
|
+
|
|
128
|
+
this.navigation.openModal<EditorModalParams>("editor", {
|
|
129
|
+
fieldKey: fieldId,
|
|
130
|
+
fieldDisplayName,
|
|
131
|
+
currentValue: fieldValue,
|
|
132
|
+
fieldConfigs: params.fieldConfigs,
|
|
133
|
+
|
|
134
|
+
onSubmit: (value: unknown) => {
|
|
135
|
+
this.navigation.replace("config" satisfies TuiRoute, {
|
|
136
|
+
...params,
|
|
137
|
+
values: { ...params.values, [fieldId]: value },
|
|
138
|
+
});
|
|
139
|
+
this.navigation.closeModal();
|
|
140
|
+
},
|
|
141
|
+
onCancel: () => {
|
|
142
|
+
this.navigation.closeModal();
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}}
|
|
146
|
+
onRun={() => {
|
|
147
|
+
void this.run(params);
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public initializeValues(cmd: AnyCommand): Record<string, unknown> {
|
|
155
|
+
const defaults: Record<string, unknown> = {};
|
|
156
|
+
const optionDefs = cmd.options as OptionSchema;
|
|
157
|
+
|
|
158
|
+
for (const [key, def] of Object.entries(optionDefs)) {
|
|
159
|
+
const typedDef = def as OptionDef;
|
|
160
|
+
if (typedDef.default !== undefined) {
|
|
161
|
+
defaults[key] = typedDef.default;
|
|
162
|
+
} else {
|
|
163
|
+
switch (typedDef.type) {
|
|
164
|
+
case "string":
|
|
165
|
+
defaults[key] = typedDef.enum?.[0] ?? "";
|
|
166
|
+
break;
|
|
167
|
+
case "number":
|
|
168
|
+
defaults[key] = typedDef.min ?? 0;
|
|
169
|
+
break;
|
|
170
|
+
case "boolean":
|
|
171
|
+
defaults[key] = false;
|
|
172
|
+
break;
|
|
173
|
+
case "array":
|
|
174
|
+
defaults[key] = [];
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const persisted = loadPersistedParameters(this.appName, cmd.name);
|
|
181
|
+
return { ...defaults, ...persisted };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { NavigationAPI } from "../context/NavigationContext.tsx";
|
|
2
|
+
|
|
3
|
+
import { RenderEditorScreen } from "../semantic/render.tsx";
|
|
4
|
+
|
|
5
|
+
import type { CopyPayload, EditorModalParams } from "../driver/types.ts";
|
|
6
|
+
|
|
7
|
+
export class EditorController {
|
|
8
|
+
private navigation: NavigationAPI;
|
|
9
|
+
|
|
10
|
+
public constructor({ navigation }: { navigation: NavigationAPI }) {
|
|
11
|
+
this.navigation = navigation;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private getSelectIndexForValue(options: { value: string }[], value: string): number {
|
|
15
|
+
const index = options.findIndex((o) => o.value === value);
|
|
16
|
+
return index >= 0 ? index : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private updateModalBuffer(params: EditorModalParams, bufferValue: string, selectIndex?: number): void {
|
|
20
|
+
this.navigation.updateModal<EditorModalParams>({
|
|
21
|
+
...params,
|
|
22
|
+
bufferValue,
|
|
23
|
+
selectIndex,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private parseValueByFieldType(type: string, valueString: string, fallback: unknown): unknown {
|
|
28
|
+
if (type === "number") {
|
|
29
|
+
const num = Number(valueString);
|
|
30
|
+
return Number.isFinite(num) ? num : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (type === "boolean") {
|
|
34
|
+
const normalized = valueString.trim().toLowerCase();
|
|
35
|
+
if (normalized === "true") return true;
|
|
36
|
+
if (normalized === "false") return false;
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return valueString;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public getCopyPayload(): CopyPayload | null {
|
|
44
|
+
const params = this.navigation.modalStack[this.navigation.modalStack.length - 1]?.params as
|
|
45
|
+
| EditorModalParams
|
|
46
|
+
| undefined;
|
|
47
|
+
if (!params) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const activeModalId = this.navigation.modalStack[this.navigation.modalStack.length - 1]?.id;
|
|
52
|
+
if (activeModalId !== "editor") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const valueString = params.bufferValue ?? String(params.currentValue ?? "");
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
label: `Field: ${params.fieldDisplayName}`,
|
|
60
|
+
content: valueString,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public render(modalParams: EditorModalParams | undefined): React.ReactNode {
|
|
65
|
+
if (!modalParams) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fieldConfig = modalParams.fieldConfigs.find((f) => f.key === modalParams.fieldKey);
|
|
70
|
+
const bufferString = modalParams.bufferValue;
|
|
71
|
+
|
|
72
|
+
if (fieldConfig?.type === "enum") {
|
|
73
|
+
const options = (fieldConfig.options ?? []).map((o) => ({
|
|
74
|
+
label: String(o.name),
|
|
75
|
+
value: String(o.value),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const currentValueString = bufferString ?? String(modalParams.currentValue ?? "");
|
|
79
|
+
const index = modalParams.selectIndex ?? this.getSelectIndexForValue(options, currentValueString);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<RenderEditorScreen
|
|
83
|
+
key="modal-editor"
|
|
84
|
+
fieldId={modalParams.fieldKey}
|
|
85
|
+
label={modalParams.fieldDisplayName}
|
|
86
|
+
valueString={options[index]?.value ?? currentValueString}
|
|
87
|
+
editorType="select"
|
|
88
|
+
selectOptions={options}
|
|
89
|
+
selectIndex={index}
|
|
90
|
+
onChangeSelectIndex={(nextIndex) => {
|
|
91
|
+
const clamped = Math.max(0, Math.min(nextIndex, Math.max(0, options.length - 1)));
|
|
92
|
+
const next = options[clamped];
|
|
93
|
+
this.updateModalBuffer(modalParams, next ? next.value : "", clamped);
|
|
94
|
+
}}
|
|
95
|
+
onSubmit={() => {
|
|
96
|
+
const valueString = bufferString ?? String(modalParams.currentValue ?? "");
|
|
97
|
+
const match = fieldConfig.options?.find((o) => String(o.value) === valueString);
|
|
98
|
+
modalParams.onSubmit(match?.value ?? valueString);
|
|
99
|
+
}}
|
|
100
|
+
onCancel={() => {
|
|
101
|
+
this.navigation.closeModal();
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (fieldConfig?.type === "boolean") {
|
|
108
|
+
const options = [
|
|
109
|
+
{ label: "true", value: "true" },
|
|
110
|
+
{ label: "false", value: "false" },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const currentBool =
|
|
114
|
+
bufferString !== undefined ? bufferString.trim().toLowerCase() === "true" : Boolean(modalParams.currentValue);
|
|
115
|
+
const index = modalParams.selectIndex ?? (currentBool ? 0 : 1);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<RenderEditorScreen
|
|
119
|
+
key="modal-editor"
|
|
120
|
+
fieldId={modalParams.fieldKey}
|
|
121
|
+
label={modalParams.fieldDisplayName}
|
|
122
|
+
valueString={options[index]!.value}
|
|
123
|
+
editorType="select"
|
|
124
|
+
selectOptions={options}
|
|
125
|
+
selectIndex={index}
|
|
126
|
+
onChangeSelectIndex={(nextIndex) => {
|
|
127
|
+
const clamped = Math.max(0, Math.min(nextIndex, 1));
|
|
128
|
+
this.updateModalBuffer(modalParams, options[clamped]!.value, clamped);
|
|
129
|
+
}}
|
|
130
|
+
onSubmit={() => {
|
|
131
|
+
const normalized = (bufferString ?? String(Boolean(modalParams.currentValue))).trim().toLowerCase();
|
|
132
|
+
modalParams.onSubmit(normalized === "true");
|
|
133
|
+
}}
|
|
134
|
+
onCancel={() => {
|
|
135
|
+
this.navigation.closeModal();
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<RenderEditorScreen
|
|
144
|
+
key="modal-editor"
|
|
145
|
+
fieldId={modalParams.fieldKey}
|
|
146
|
+
label={modalParams.fieldDisplayName}
|
|
147
|
+
valueString={bufferString ?? String(modalParams.currentValue ?? "")}
|
|
148
|
+
editorType={fieldConfig?.type === "number" ? "number" : "text"}
|
|
149
|
+
onChangeText={(text) => {
|
|
150
|
+
this.updateModalBuffer(modalParams, text);
|
|
151
|
+
}}
|
|
152
|
+
onSubmit={() => {
|
|
153
|
+
const valueString = bufferString ?? String(modalParams.currentValue ?? "");
|
|
154
|
+
|
|
155
|
+
if (!fieldConfig) {
|
|
156
|
+
modalParams.onSubmit(valueString);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsed = this.parseValueByFieldType(fieldConfig.type, valueString, modalParams.currentValue);
|
|
161
|
+
modalParams.onSubmit(parsed);
|
|
162
|
+
}}
|
|
163
|
+
onCancel={() => {
|
|
164
|
+
this.navigation.closeModal();
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { LogLevel, type LogEvent } from "../../core/logger.ts";
|
|
2
|
+
import type { NavigationAPI } from "../context/NavigationContext.tsx";
|
|
3
|
+
|
|
4
|
+
import { RenderLogsScreen } from "../semantic/render.tsx";
|
|
5
|
+
|
|
6
|
+
import type { CopyPayload } from "../driver/types.ts";
|
|
7
|
+
|
|
8
|
+
export class LogsController {
|
|
9
|
+
private navigation: NavigationAPI;
|
|
10
|
+
|
|
11
|
+
public constructor({ navigation }: { navigation: NavigationAPI }) {
|
|
12
|
+
this.navigation = navigation;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public getCopyPayload(logs: LogEvent[]): CopyPayload {
|
|
16
|
+
const text = this.formatLogText(logs);
|
|
17
|
+
return { label: "Logs", content: text };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public render(logs: LogEvent[]): React.ReactNode {
|
|
21
|
+
const items = this.formatLogItems(logs);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<RenderLogsScreen
|
|
25
|
+
key="modal-logs"
|
|
26
|
+
items={items}
|
|
27
|
+
onClose={() => this.navigation.closeModal()}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private formatLogItems(logs: LogEvent[]): { level: string; message: string; timestamp: number }[] {
|
|
33
|
+
return logs.map((l) => ({
|
|
34
|
+
level: LogLevel[l.level],
|
|
35
|
+
message: l.message,
|
|
36
|
+
timestamp: l.timestamp.getTime(),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private formatLogText(logs: LogEvent[]): string {
|
|
41
|
+
return logs
|
|
42
|
+
.map((l) => {
|
|
43
|
+
const timestamp = l.timestamp.toISOString();
|
|
44
|
+
return `[${timestamp}] ${LogLevel[l.level].toUpperCase()}: ${l.message}`;
|
|
45
|
+
})
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CommandResult } from "../../core/command.ts";
|
|
2
|
+
import type { CopyPayload, ErrorRouteParams, ResultsRouteParams, TuiRoute } from "../driver/types.ts";
|
|
3
|
+
import type { NavigationAPI } from "../context/NavigationContext.tsx";
|
|
4
|
+
|
|
5
|
+
import { RenderRunningScreen } from "../semantic/render.tsx";
|
|
6
|
+
|
|
7
|
+
export type OutcomeRoute = Extract<TuiRoute, "running" | "results" | "error">;
|
|
8
|
+
|
|
9
|
+
export class OutcomeController {
|
|
10
|
+
private navigation: NavigationAPI;
|
|
11
|
+
|
|
12
|
+
public constructor({ navigation }: { navigation: NavigationAPI }) {
|
|
13
|
+
this.navigation = navigation;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public render(route: OutcomeRoute): { node: React.ReactNode } {
|
|
17
|
+
if (route === "running") {
|
|
18
|
+
return { node: <RenderRunningScreen title="Waiting for results..." kind="running" /> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (route === "results") {
|
|
22
|
+
const params = this.navigation.current.params as ResultsRouteParams | undefined;
|
|
23
|
+
const result = params?.result as CommandResult | undefined;
|
|
24
|
+
const command = params?.command;
|
|
25
|
+
|
|
26
|
+
// Check if command has a custom result renderer
|
|
27
|
+
let customContent: React.ReactNode = undefined;
|
|
28
|
+
if (result && command?.renderResult) {
|
|
29
|
+
try {
|
|
30
|
+
customContent = command.renderResult(result);
|
|
31
|
+
} catch {
|
|
32
|
+
// If custom renderer fails, fall back to default display
|
|
33
|
+
customContent = undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
node: (
|
|
39
|
+
<RenderRunningScreen
|
|
40
|
+
title="Results"
|
|
41
|
+
kind="results"
|
|
42
|
+
message={result?.message}
|
|
43
|
+
result={result}
|
|
44
|
+
customContent={customContent}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const params = this.navigation.current.params as { error: Error } | undefined;
|
|
51
|
+
return {
|
|
52
|
+
node: (
|
|
53
|
+
<RenderRunningScreen
|
|
54
|
+
title="Error"
|
|
55
|
+
kind="error"
|
|
56
|
+
message={String(params?.error?.message ?? "Unknown error")}
|
|
57
|
+
/>
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getCopyPayload(route: OutcomeRoute): CopyPayload | null {
|
|
63
|
+
if (route === "results") {
|
|
64
|
+
const params = this.navigation.current.params as ResultsRouteParams | undefined;
|
|
65
|
+
const result = params?.result as CommandResult | undefined;
|
|
66
|
+
const command = params?.command;
|
|
67
|
+
|
|
68
|
+
// Check if command has a custom clipboard content provider
|
|
69
|
+
if (result && command?.getClipboardContent) {
|
|
70
|
+
try {
|
|
71
|
+
const content = command.getClipboardContent(result);
|
|
72
|
+
if (content !== undefined) {
|
|
73
|
+
return { label: "result", content };
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Fall through to default behavior
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result !== undefined) {
|
|
81
|
+
// If result has data, stringify it for clipboard
|
|
82
|
+
if (result.data !== undefined) {
|
|
83
|
+
return {
|
|
84
|
+
label: "result",
|
|
85
|
+
content: typeof result.data === "object"
|
|
86
|
+
? JSON.stringify(result.data, null, 2)
|
|
87
|
+
: String(result.data),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Otherwise use the message
|
|
91
|
+
return {
|
|
92
|
+
label: "result",
|
|
93
|
+
content: result.message ?? "",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (route === "error") {
|
|
99
|
+
const params = this.navigation.current.params as ErrorRouteParams | undefined;
|
|
100
|
+
if (params?.error) {
|
|
101
|
+
return {
|
|
102
|
+
label: "error",
|
|
103
|
+
content: params.error.message ?? String(params.error),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|