@plainconceptsplatform/loop-task 2.6.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/LICENSE +21 -0
- package/README.md +534 -0
- package/dist/app/App.js +132 -0
- package/dist/app/BoardLayout.js +5 -0
- package/dist/app/index.js +48 -0
- package/dist/app/providers/index.js +1 -0
- package/dist/app/router/index.js +16 -0
- package/dist/app/types.js +1 -0
- package/dist/cli/import-validator.js +152 -0
- package/dist/cli/import-writer.js +81 -0
- package/dist/cli.js +573 -0
- package/dist/client/cli-format.js +7 -0
- package/dist/client/commands.js +162 -0
- package/dist/client/ipc.js +96 -0
- package/dist/client/project-commands.js +164 -0
- package/dist/core/command/command-runner.js +300 -0
- package/dist/core/command/process-tree.js +73 -0
- package/dist/core/command/resolve-cwd.js +8 -0
- package/dist/core/command/stdout-capture-transform.js +73 -0
- package/dist/core/context/context-parser.js +65 -0
- package/dist/core/context/log-context.js +3 -0
- package/dist/core/context/template.js +20 -0
- package/dist/core/context/validate-context.js +19 -0
- package/dist/core/foreground/index.js +81 -0
- package/dist/core/logging/bounded-log-reader.js +368 -0
- package/dist/core/logging/log-follower.js +26 -0
- package/dist/core/logging/log-parser.js +29 -0
- package/dist/core/logging/log-rotator.js +34 -0
- package/dist/core/logging/rotating-log-stream.js +137 -0
- package/dist/core/loop/chain-executor.js +171 -0
- package/dist/core/loop/delay-utils.js +65 -0
- package/dist/core/loop/loop-controller.js +240 -0
- package/dist/core/loop/loop-runner.js +194 -0
- package/dist/core/loop/run-executor.js +144 -0
- package/dist/core/loop/types.js +1 -0
- package/dist/core/scheduling/index.js +16 -0
- package/dist/daemon/daemon-log.js +11 -0
- package/dist/daemon/diagnostics.js +85 -0
- package/dist/daemon/http/helpers.js +81 -0
- package/dist/daemon/http/openapi.js +119 -0
- package/dist/daemon/http/route-loops.js +311 -0
- package/dist/daemon/http/route-misc.js +60 -0
- package/dist/daemon/http/route-projects.js +55 -0
- package/dist/daemon/http/route-tasks.js +141 -0
- package/dist/daemon/http/routes.js +19 -0
- package/dist/daemon/http/server.js +82 -0
- package/dist/daemon/http/sse.js +36 -0
- package/dist/daemon/index.js +189 -0
- package/dist/daemon/ipc/send.js +5 -0
- package/dist/daemon/managers/loop-entry.js +27 -0
- package/dist/daemon/managers/loop-manager.js +346 -0
- package/dist/daemon/managers/loop-options.js +17 -0
- package/dist/daemon/managers/loop-serialization.js +53 -0
- package/dist/daemon/managers/project-manager.js +170 -0
- package/dist/daemon/managers/task-manager.js +94 -0
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +167 -0
- package/dist/daemon/mcp/tools.js +382 -0
- package/dist/daemon/recipe/deferred-reload.js +32 -0
- package/dist/daemon/recipe/diagram-reader.js +26 -0
- package/dist/daemon/recipe/file-writer.js +52 -0
- package/dist/daemon/recipe/id-remapper.js +46 -0
- package/dist/daemon/recipe/runtime-state.js +23 -0
- package/dist/daemon/recipe/scanner.js +165 -0
- package/dist/daemon/recipe/task-store.js +33 -0
- package/dist/daemon/recipe/validator.js +35 -0
- package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
- package/dist/daemon/server/handlers/index.js +101 -0
- package/dist/daemon/server/handlers/log-handlers.js +129 -0
- package/dist/daemon/server/handlers/loop-handlers.js +64 -0
- package/dist/daemon/server/handlers/project-handlers.js +42 -0
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/handlers/task-handlers.js +20 -0
- package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
- package/dist/daemon/server/index.js +117 -0
- package/dist/daemon/settings-manager.js +73 -0
- package/dist/daemon/spawner/index.js +204 -0
- package/dist/daemon/state/index.js +209 -0
- package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
- package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
- package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
- package/dist/daemon/telemetry/index.js +6 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
- package/dist/daemon/telemetry/telemetry-manager.js +147 -0
- package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
- package/dist/daemon/telemetry/telemetry-types.js +45 -0
- package/dist/daemon/telemetry/telemetry.js +1 -0
- package/dist/daemon/watcher/index.js +250 -0
- package/dist/duration.js +24 -0
- package/dist/entities/loops/filters.js +109 -0
- package/dist/entities/loops/index.js +1 -0
- package/dist/entities/projects/filters.js +69 -0
- package/dist/entities/projects/index.js +1 -0
- package/dist/entities/tasks/filters.js +41 -0
- package/dist/entities/tasks/index.js +1 -0
- package/dist/entry.js +16 -0
- package/dist/esm-loader.js +39 -0
- package/dist/features/chain-editor/ChainEditor.js +116 -0
- package/dist/features/chain-editor/mermaidToAscii.js +375 -0
- package/dist/features/chain-editor/renderChainDiagram.js +106 -0
- package/dist/features/code-editor/CodeEditorModal.js +84 -0
- package/dist/features/code-editor/CodeEditorPreview.js +14 -0
- package/dist/features/code-editor/useEditorKeyboard.js +150 -0
- package/dist/features/code-editor/useModalDimensions.js +9 -0
- package/dist/features/commands/commands.js +132 -0
- package/dist/features/commands/useCommandHandlers.js +280 -0
- package/dist/features/commands/useContextualActions.js +78 -0
- package/dist/features/commands/useGlobalShortcuts.js +154 -0
- package/dist/features/forms/FormRouter.js +46 -0
- package/dist/features/overlays/ContextHelpModal.js +8 -0
- package/dist/features/overlays/DiagramModal.js +45 -0
- package/dist/features/overlays/ExportModal.js +51 -0
- package/dist/features/overlays/HelpGuideModal.js +23 -0
- package/dist/features/overlays/HelpModal.js +59 -0
- package/dist/features/overlays/OverlayStack.js +10 -0
- package/dist/features/overlays/ProjectsModal.js +42 -0
- package/dist/features/overlays/TaskPickerModal.js +62 -0
- package/dist/features/overlays/WelcomeScreen.js +74 -0
- package/dist/features/overlays/useOverlayStack.js +55 -0
- package/dist/features/state/useAppState.js +151 -0
- package/dist/logger.js +25 -0
- package/dist/loop-config.js +152 -0
- package/dist/shared/clipboard.js +111 -0
- package/dist/shared/config/constants.js +104 -0
- package/dist/shared/config/paths.js +67 -0
- package/dist/shared/container/index.js +24 -0
- package/dist/shared/fs-utils.js +44 -0
- package/dist/shared/hooks/useBreakpoint.js +11 -0
- package/dist/shared/hooks/useDaemonSettings.js +41 -0
- package/dist/shared/hooks/useInject.js +6 -0
- package/dist/shared/hooks/useLogStream.js +37 -0
- package/dist/shared/hooks/useLoopFormValidation.js +131 -0
- package/dist/shared/hooks/useLoopPolling.js +30 -0
- package/dist/shared/hooks/useMouseScroll.js +56 -0
- package/dist/shared/hooks/useUndoRedo.js +77 -0
- package/dist/shared/i18n/en.json +709 -0
- package/dist/shared/i18n/index.js +11 -0
- package/dist/shared/providers/InversifyProvider.js +6 -0
- package/dist/shared/services/export-service.js +43 -0
- package/dist/shared/services/log-service.js +28 -0
- package/dist/shared/services/loop-service.js +78 -0
- package/dist/shared/services/project-service.js +39 -0
- package/dist/shared/services/settings-service.js +102 -0
- package/dist/shared/services/task-service.js +41 -0
- package/dist/shared/services/types.js +8 -0
- package/dist/shared/sleep.js +17 -0
- package/dist/shared/tail.js +4 -0
- package/dist/shared/ui/Button.js +18 -0
- package/dist/shared/ui/DebugPanel.js +9 -0
- package/dist/shared/ui/FocusableButton.js +18 -0
- package/dist/shared/ui/FocusableInput.js +72 -0
- package/dist/shared/ui/FocusableList.js +48 -0
- package/dist/shared/ui/Modal.js +13 -0
- package/dist/shared/ui/SelectModal.js +78 -0
- package/dist/shared/ui/Toast.js +37 -0
- package/dist/shared/ui/format.js +117 -0
- package/dist/shared/ui/hooks/useHoverState.js +11 -0
- package/dist/shared/ui/state.js +9 -0
- package/dist/shared/ui/theme.js +96 -0
- package/dist/shared/utils/log-lines.js +13 -0
- package/dist/shared/utils/paste.js +37 -0
- package/dist/shared/utils/syntax.js +128 -0
- package/dist/shared/utils/validation.js +56 -0
- package/dist/types.js +1 -0
- package/dist/visual-evidence/capture.js +51 -0
- package/dist/visual-evidence/cli.js +85 -0
- package/dist/visual-evidence/evidence-required.js +71 -0
- package/dist/visual-evidence/index.js +10 -0
- package/dist/visual-evidence/launch.js +61 -0
- package/dist/visual-evidence/manifest.js +32 -0
- package/dist/visual-evidence/openspec-resolver.js +45 -0
- package/dist/visual-evidence/publish.js +126 -0
- package/dist/visual-evidence/run.js +121 -0
- package/dist/visual-evidence/scenario-registry.js +132 -0
- package/dist/widgets/command-input/CommandDropdown.js +69 -0
- package/dist/widgets/command-input/CommandInput.js +133 -0
- package/dist/widgets/command-input/HintBar.js +9 -0
- package/dist/widgets/command-input/InputModes.js +72 -0
- package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
- package/dist/widgets/header/Header.js +54 -0
- package/dist/widgets/header/TabBar.js +20 -0
- package/dist/widgets/left-panel/LeftPanel.js +34 -0
- package/dist/widgets/left-panel/Navigator.js +107 -0
- package/dist/widgets/left-panel/ProjectsPage.js +76 -0
- package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
- package/dist/widgets/left-panel/TaskBrowser.js +132 -0
- package/dist/widgets/log-modal/LogModal.js +211 -0
- package/dist/widgets/loop-form/CreateForm.js +82 -0
- package/dist/widgets/loop-form/TextField.js +9 -0
- package/dist/widgets/loop-form/WizardForm.js +199 -0
- package/dist/widgets/loop-form/useCreateSteps.js +135 -0
- package/dist/widgets/loop-form/useHandleComplete.js +94 -0
- package/dist/widgets/project-form/ProjectForm.js +83 -0
- package/dist/widgets/right-panel/Inspector.js +41 -0
- package/dist/widgets/right-panel/RightPanel.js +38 -0
- package/dist/widgets/right-panel/RunHistory.js +187 -0
- package/dist/widgets/task-form/TaskForm.js +272 -0
- package/package.json +119 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { ScrollList } from "ink-scroll-list";
|
|
5
|
+
import { darkTheme as theme } from "../../shared/ui/theme.js";
|
|
6
|
+
import { commandLine } from "../../shared/ui/format.js";
|
|
7
|
+
import { t } from "../../shared/i18n/index.js";
|
|
8
|
+
import { FocusableButton } from "../../shared/ui/FocusableButton.js";
|
|
9
|
+
import { useMouseScroll } from "../../shared/hooks/useMouseScroll.js";
|
|
10
|
+
const NAME_WIDTH = 24;
|
|
11
|
+
const COMMAND_WIDTH = 28;
|
|
12
|
+
const PROJECT_WIDTH = 10;
|
|
13
|
+
const LIMIT = 15;
|
|
14
|
+
export function TaskNavigator(props) {
|
|
15
|
+
const { visible, total, selectedIndex, isFocused, navActive = true, allTasks, projects, onSelect, onActivate } = props;
|
|
16
|
+
const title = t("board.taskBrowserTitle", {
|
|
17
|
+
visible: String(visible.length),
|
|
18
|
+
total: String(total),
|
|
19
|
+
});
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
// Swallow SGR mouse sequences so they don't interfere
|
|
22
|
+
if (input.includes("[<")) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (key.upArrow || input === "k") {
|
|
26
|
+
onSelect(Math.max(selectedIndex - 1, 0));
|
|
27
|
+
}
|
|
28
|
+
else if (key.downArrow || input === "j") {
|
|
29
|
+
onSelect(Math.min(selectedIndex + 1, visible.length - 1));
|
|
30
|
+
}
|
|
31
|
+
else if (key.ctrl && key.return) {
|
|
32
|
+
onActivate(selectedIndex);
|
|
33
|
+
}
|
|
34
|
+
}, { isActive: isFocused && navActive });
|
|
35
|
+
useMouseScroll({
|
|
36
|
+
onScrollUp: () => {
|
|
37
|
+
if (!(isFocused && navActive))
|
|
38
|
+
return;
|
|
39
|
+
onSelect(Math.max(selectedIndex - 1, 0));
|
|
40
|
+
},
|
|
41
|
+
onScrollDown: () => {
|
|
42
|
+
if (!(isFocused && navActive))
|
|
43
|
+
return;
|
|
44
|
+
onSelect(Math.min(selectedIndex + 1, visible.length - 1));
|
|
45
|
+
},
|
|
46
|
+
isActive: isFocused && navActive,
|
|
47
|
+
});
|
|
48
|
+
function resolveName(id) {
|
|
49
|
+
if (id === null)
|
|
50
|
+
return null;
|
|
51
|
+
return allTasks.find((t) => t.id === id)?.name ?? id;
|
|
52
|
+
}
|
|
53
|
+
function chainsLabel(task) {
|
|
54
|
+
const hasSuccess = task.onSuccessTaskId !== null;
|
|
55
|
+
const hasFailure = task.onFailureTaskId !== null;
|
|
56
|
+
if (!hasSuccess && !hasFailure)
|
|
57
|
+
return t("board.taskChainsNone");
|
|
58
|
+
const successName = hasSuccess ? resolveName(task.onSuccessTaskId) : null;
|
|
59
|
+
const failureName = hasFailure ? resolveName(task.onFailureTaskId) : null;
|
|
60
|
+
return t("board.taskChainsFormat", {
|
|
61
|
+
success: hasSuccess ? `\u2713${successName}` : "-",
|
|
62
|
+
failure: hasFailure ? `\u2192${failureName}` : "-",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function resolveProjectName(task) {
|
|
66
|
+
const proj = projects.find((p) => p.id === task.projectId);
|
|
67
|
+
return proj?.name ?? "";
|
|
68
|
+
}
|
|
69
|
+
function renderTask(task, index) {
|
|
70
|
+
const isSelected = index === selectedIndex;
|
|
71
|
+
const name = task.name.length > NAME_WIDTH
|
|
72
|
+
? task.name.slice(0, NAME_WIDTH - 3) + "..."
|
|
73
|
+
: task.name.padEnd(NAME_WIDTH);
|
|
74
|
+
const cmd = commandLine(task.command, task.commandArgs);
|
|
75
|
+
const cmdDisplay = cmd.length > COMMAND_WIDTH
|
|
76
|
+
? cmd.slice(0, COMMAND_WIDTH - 3) + "..."
|
|
77
|
+
: cmd.padEnd(COMMAND_WIDTH);
|
|
78
|
+
const chains = chainsLabel(task);
|
|
79
|
+
const silent = task.silentChain ? " [s]" : "";
|
|
80
|
+
const recipeBadge = task.isRecipeTask ? "R " : "";
|
|
81
|
+
const projectTag = task.isRecipeTask && task.projectId ? `${resolveProjectName(task)}: ` : "";
|
|
82
|
+
const projectDisplay = projectTag.length > PROJECT_WIDTH
|
|
83
|
+
? projectTag.slice(0, PROJECT_WIDTH - 3) + "..."
|
|
84
|
+
: projectTag.padEnd(PROJECT_WIDTH);
|
|
85
|
+
const fg = isSelected ? theme.text.inverse : theme.text.primary;
|
|
86
|
+
const recipeFg = isSelected ? theme.text.inverse : theme.accent.brand;
|
|
87
|
+
return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.activeTask : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: recipeFg, children: recipeBadge }), _jsxs(Text, { color: fg, children: [projectDisplay, name] }), _jsx(Text, { color: fg, children: cmdDisplay }), _jsxs(Text, { color: fg, children: [chains, silent] })] }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.taskBrowserEmpty") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderName").padEnd(NAME_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderCommand").padEnd(COMMAND_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderChains") })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { height: LIMIT, selectedIndex: selectedIndex, scrollAlignment: "auto", children: visible.map((task, i) => (_jsx(React.Fragment, { children: renderTask(task, i) }, task.id))) }) })] }))] }));
|
|
90
|
+
}
|
|
91
|
+
const FIELD_LABEL_WIDTH = 13;
|
|
92
|
+
function InspectorField(props) {
|
|
93
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.text.secondary, children: props.label.padEnd(FIELD_LABEL_WIDTH) }), _jsx(Text, { color: theme.text.primary, children: props.children })] }));
|
|
94
|
+
}
|
|
95
|
+
export function TaskInspector(props) {
|
|
96
|
+
const { task, allTasks } = props;
|
|
97
|
+
if (!task) {
|
|
98
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorEmpty") }) })] }));
|
|
99
|
+
}
|
|
100
|
+
const cmd = commandLine(task.command, task.commandArgs);
|
|
101
|
+
const resolveName = (id) => {
|
|
102
|
+
if (id === null)
|
|
103
|
+
return null;
|
|
104
|
+
return allTasks.find((t) => t.id === id)?.name ?? id;
|
|
105
|
+
};
|
|
106
|
+
const onSuccess = task.onSuccessTaskId ? resolveName(task.onSuccessTaskId) ?? task.onSuccessTaskId : t("board.taskNone");
|
|
107
|
+
const onFailure = task.onFailureTaskId ? resolveName(task.onFailureTaskId) ?? task.onFailureTaskId : t("board.taskNone");
|
|
108
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(InspectorField, { label: t("board.fieldId"), children: _jsx(Text, { color: theme.text.primary, children: task.id }) }), _jsx(InspectorField, { label: t("board.taskLabelName") + ": ", children: _jsx(Text, { color: theme.text.primary, children: task.name }) }), _jsx(InspectorField, { label: t("board.taskLabelCommand") + ": ", children: _jsx(Text, { color: theme.text.primary, children: cmd }) }), _jsx(InspectorField, { label: t("board.taskLabelOnSuccess") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onSuccess }) }), _jsx(InspectorField, { label: t("board.taskLabelOnFailure") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onFailure }) }), _jsx(InspectorField, { label: t("board.taskFieldSilent") + ": ", children: _jsx(Text, { color: task.silentChain ? theme.semantic.warning : theme.text.muted, children: task.silentChain ? t("board.silentChainYes") : t("board.silentChainNo") }) })] })] }));
|
|
109
|
+
}
|
|
110
|
+
export function TaskActionButtons(props) {
|
|
111
|
+
const { task, selectable, onAction } = props;
|
|
112
|
+
const isRecipe = task?.isRecipeTask === true;
|
|
113
|
+
const actions = isRecipe
|
|
114
|
+
? [] // Recipe tasks are immutable — no actions allowed
|
|
115
|
+
: selectable
|
|
116
|
+
? [
|
|
117
|
+
{ key: "select", label: t("board.taskActionSelect") },
|
|
118
|
+
{ key: "edit", label: t("board.taskActionEdit") },
|
|
119
|
+
{ key: "delete", label: t("board.taskActionDelete") },
|
|
120
|
+
]
|
|
121
|
+
: [
|
|
122
|
+
{ key: "edit", label: t("board.taskActionEdit") },
|
|
123
|
+
{ key: "delete", label: t("board.taskActionDelete") },
|
|
124
|
+
];
|
|
125
|
+
if (!task) {
|
|
126
|
+
return (_jsx(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "row", height: 3, backgroundColor: theme.bg.surface, children: _jsx(Text, { color: theme.text.muted, children: t("board.noActions") }) }));
|
|
127
|
+
}
|
|
128
|
+
if (isRecipe) {
|
|
129
|
+
return (_jsx(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "row", height: 3, backgroundColor: theme.bg.surface, children: _jsx(Text, { color: theme.text.muted, children: t("task.recipeImmutableShort") }) }));
|
|
130
|
+
}
|
|
131
|
+
return (_jsx(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "row", height: 3, backgroundColor: theme.bg.surface, children: actions.map((action) => (_jsx(FocusableButton, { label: action.label, color: action.key === "delete" ? theme.semantic.danger : theme.text.secondary, variant: action.key === "delete" ? "danger" : "default", onPress: () => onAction(action.key) }, action.key))) }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
4
|
+
import { darkTheme as theme } from "../../shared/ui/theme.js";
|
|
5
|
+
import { t } from "../../shared/i18n/index.js";
|
|
6
|
+
import { useInject } from "../../shared/hooks/useInject.js";
|
|
7
|
+
import { TYPES } from "../../shared/services/types.js";
|
|
8
|
+
import { formatDate } from "../../shared/ui/format.js";
|
|
9
|
+
import { copyToClipboard } from "../../shared/clipboard.js";
|
|
10
|
+
import { LOG_MODAL_LINES_MAX } from "../../shared/config/constants.js";
|
|
11
|
+
import { appendClamped, clampLines } from "../../shared/utils/log-lines.js";
|
|
12
|
+
import { useMouseScroll } from "../../shared/hooks/useMouseScroll.js";
|
|
13
|
+
function colorForLine(line, run) {
|
|
14
|
+
if (line.includes("[Run #"))
|
|
15
|
+
return theme.accent.loop;
|
|
16
|
+
if (line.startsWith("$ "))
|
|
17
|
+
return "#f0abfc";
|
|
18
|
+
if (line.startsWith(" cwd:"))
|
|
19
|
+
return theme.text.muted;
|
|
20
|
+
if (line.includes("--- Chain:"))
|
|
21
|
+
return theme.accent.task;
|
|
22
|
+
if (line.trimStart().startsWith("[exit")) {
|
|
23
|
+
const match = /\[exit\s+(\d+)/.exec(line);
|
|
24
|
+
const code = match ? Number(match[1]) : run.exitCode;
|
|
25
|
+
return code === 0 ? theme.semantic.success : theme.semantic.danger;
|
|
26
|
+
}
|
|
27
|
+
return theme.text.primary;
|
|
28
|
+
}
|
|
29
|
+
const MAX_VALUE_LENGTH = 60;
|
|
30
|
+
function expandJsonLine(line) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
|
|
33
|
+
return null;
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(trimmed);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
42
|
+
return null;
|
|
43
|
+
const lines = ["context: {"];
|
|
44
|
+
for (const key of Object.keys(parsed)) {
|
|
45
|
+
const value = parsed[key];
|
|
46
|
+
let display;
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
display = value.length > MAX_VALUE_LENGTH
|
|
49
|
+
? `"${value.slice(0, MAX_VALUE_LENGTH)}..."`
|
|
50
|
+
: `"${value}"`;
|
|
51
|
+
}
|
|
52
|
+
else if (typeof value === "number" || typeof value === "boolean") {
|
|
53
|
+
display = String(value);
|
|
54
|
+
}
|
|
55
|
+
else if (value === null) {
|
|
56
|
+
display = "null";
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
display = JSON.stringify(value);
|
|
60
|
+
}
|
|
61
|
+
lines.push(` ${key}: ${display}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push("}");
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
export function LogModal(props) {
|
|
67
|
+
const [lines, setLines] = useState(() => clampLines(props.logLines, LOG_MODAL_LINES_MAX));
|
|
68
|
+
const [streaming, setStreaming] = useState(false);
|
|
69
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
70
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
71
|
+
const [follow, setFollow] = useState(true);
|
|
72
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
73
|
+
const injectedLogService = useInject(TYPES.LogService);
|
|
74
|
+
const logServiceRef = useRef(injectedLogService);
|
|
75
|
+
const logService = logServiceRef.current;
|
|
76
|
+
const { stdout } = useStdout();
|
|
77
|
+
const terminalHeight = stdout?.rows ?? 24;
|
|
78
|
+
const MAX_VISIBLE_LINES = Math.max(1, Math.floor(terminalHeight * 0.7) - 7);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setLines(clampLines(props.logLines, LOG_MODAL_LINES_MAX));
|
|
81
|
+
}, [props.logLines]);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!props.loopId)
|
|
84
|
+
return;
|
|
85
|
+
if (props.run.status !== "running")
|
|
86
|
+
return;
|
|
87
|
+
setStreaming(true);
|
|
88
|
+
const socket = logService.streamRunLog(props.loopId, props.run.runNumber, (line) => setLines((prev) => appendClamped(prev, line, LOG_MODAL_LINES_MAX)), () => setStreaming(false), () => setStreaming(false));
|
|
89
|
+
return () => {
|
|
90
|
+
socket.destroy();
|
|
91
|
+
};
|
|
92
|
+
}, [props.loopId, props.run.runNumber, props.run.status, logService]);
|
|
93
|
+
const filtered = searchQuery
|
|
94
|
+
? lines.filter((l) => l.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
95
|
+
: lines;
|
|
96
|
+
const totalLines = filtered.length;
|
|
97
|
+
const startIdx = follow
|
|
98
|
+
? Math.max(0, totalLines - MAX_VISIBLE_LINES)
|
|
99
|
+
: scrollOffset;
|
|
100
|
+
const endIdx = startIdx + MAX_VISIBLE_LINES;
|
|
101
|
+
const visible = filtered.slice(startIdx, endIdx);
|
|
102
|
+
useInput((input, key) => {
|
|
103
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
|
|
104
|
+
// before the escape check the leading ESC trips key.escape and would
|
|
105
|
+
// close the modal before the paste is acknowledged. LogModal doesn't
|
|
106
|
+
// insert pastes, so just swallow the sequence.
|
|
107
|
+
if (input.includes("\x1b[200~")) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Swallow SGR mouse sequences so they don't trigger key.escape
|
|
111
|
+
if (input.includes("[<")) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (searchMode) {
|
|
115
|
+
if (key.escape || key.return) {
|
|
116
|
+
setSearchMode(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.backspace || key.delete) {
|
|
120
|
+
setSearchQuery((q) => q.slice(0, -1));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (input && !key.ctrl && input.length === 1 && input >= " " && input <= "~") {
|
|
124
|
+
setSearchQuery((q) => q + input);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (key.escape) {
|
|
130
|
+
props.onClose();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (input === "/") {
|
|
134
|
+
setSearchMode(true);
|
|
135
|
+
setSearchQuery("");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (key.ctrl && input === "x") {
|
|
139
|
+
copyToClipboard(lines.join("\n"));
|
|
140
|
+
props.onCopy?.();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (key.ctrl && input === "t") {
|
|
144
|
+
setFollow(false);
|
|
145
|
+
setScrollOffset(0);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (key.ctrl && input === "b") {
|
|
149
|
+
setFollow(true);
|
|
150
|
+
setScrollOffset(0);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (key.downArrow) {
|
|
154
|
+
const base = follow ? Math.max(0, totalLines - MAX_VISIBLE_LINES) : scrollOffset;
|
|
155
|
+
setFollow(false);
|
|
156
|
+
setScrollOffset(Math.min(base + 1, Math.max(0, totalLines - MAX_VISIBLE_LINES)));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (key.upArrow) {
|
|
160
|
+
const base = follow ? Math.max(0, totalLines - MAX_VISIBLE_LINES) : scrollOffset;
|
|
161
|
+
setFollow(false);
|
|
162
|
+
setScrollOffset(Math.max(0, base - 1));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
useMouseScroll({
|
|
167
|
+
onScrollUp: () => {
|
|
168
|
+
if (searchMode)
|
|
169
|
+
return;
|
|
170
|
+
const base = follow ? Math.max(0, totalLines - MAX_VISIBLE_LINES) : scrollOffset;
|
|
171
|
+
setFollow(false);
|
|
172
|
+
setScrollOffset(Math.max(0, base - 1));
|
|
173
|
+
},
|
|
174
|
+
onScrollDown: () => {
|
|
175
|
+
if (searchMode)
|
|
176
|
+
return;
|
|
177
|
+
const base = follow ? Math.max(0, totalLines - MAX_VISIBLE_LINES) : scrollOffset;
|
|
178
|
+
setFollow(false);
|
|
179
|
+
setScrollOffset(Math.min(base + 1, Math.max(0, totalLines - MAX_VISIBLE_LINES)));
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const isLoading = props.loading || streaming;
|
|
183
|
+
const statusIcon = props.run.status === "running"
|
|
184
|
+
? "\u21bb"
|
|
185
|
+
: props.run.exitCode === 0
|
|
186
|
+
? "\u2713"
|
|
187
|
+
: "\u2717";
|
|
188
|
+
const statusColor = props.run.status === "running"
|
|
189
|
+
? theme.semantic.warning
|
|
190
|
+
: props.run.exitCode === 0
|
|
191
|
+
? theme.semantic.success
|
|
192
|
+
: theme.semantic.danger;
|
|
193
|
+
return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { borderStyle: "round", borderColor: theme.accent.brand, backgroundColor: theme.bg.elevated, paddingX: 2, paddingY: 1, flexDirection: "column", width: "95%", height: "70%", children: [_jsx(Text, { color: theme.accent.brand, bold: true, children: `Run #${props.run.runNumber} - ${formatDate(props.run.startedAt)}` }), _jsxs(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " ", props.run.status === "running"
|
|
194
|
+
? t("board.logModalRunning")
|
|
195
|
+
: t("board.logModalExit", { code: props.run.exitCode })] }), props.run.duration > 0 ? (_jsxs(Text, { color: theme.text.muted, children: [" ", t("board.logModalDuration"), " ", props.run.duration, "ms"] })) : null, _jsx(Text, { color: theme.text.muted, children: searchMode
|
|
196
|
+
? `/${searchQuery}`
|
|
197
|
+
: `[${startIdx}-${endIdx}/${totalLines}]` })] }), searchMode ? (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.text.muted, children: "Search: " }), _jsxs(Text, { color: theme.text.primary, children: [searchQuery, "_"] })] })) : null, _jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: visible.length === 0 ? (_jsx(Text, { color: theme.text.muted, children: isLoading
|
|
198
|
+
? t("board.logModalLoading")
|
|
199
|
+
: searchQuery
|
|
200
|
+
? t("board.logModalNoMatches")
|
|
201
|
+
: t("board.logModalEmpty") })) : (visible.map((line, i) => {
|
|
202
|
+
const realIdx = startIdx + i;
|
|
203
|
+
const expanded = expandJsonLine(line);
|
|
204
|
+
if (expanded) {
|
|
205
|
+
return (_jsx(React.Fragment, { children: expanded.map((sub, j) => (_jsx(Text, { color: j === 0 ? theme.accent.task : theme.text.muted, wrap: "truncate", children: sub }, `${realIdx}-${j}`))) }, realIdx));
|
|
206
|
+
}
|
|
207
|
+
return (_jsx(Text, { color: colorForLine(line, props.run), wrap: "truncate", children: line }, realIdx));
|
|
208
|
+
})) }), _jsx(Box, { marginTop: 1, justifyContent: "space-between", children: _jsx(Text, { color: theme.text.muted, children: searchMode
|
|
209
|
+
? t("board.logModalSearchHint")
|
|
210
|
+
: t("board.logModalFooterHints") }) })] })] }) }));
|
|
211
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useCallback, useState, useRef } from "react";
|
|
3
|
+
import { useInject } from "../../shared/hooks/useInject.js";
|
|
4
|
+
import { TYPES } from "../../shared/services/types.js";
|
|
5
|
+
import { t } from "../../shared/i18n/index.js";
|
|
6
|
+
import { WizardForm } from "./WizardForm.js";
|
|
7
|
+
import { TaskPickerModal } from "../../features/overlays/TaskPickerModal.js";
|
|
8
|
+
import { SelectModal } from "../../shared/ui/SelectModal.js";
|
|
9
|
+
import { CodeEditorModal } from "../../features/code-editor/CodeEditorModal.js";
|
|
10
|
+
import { useCreateSteps } from "./useCreateSteps.js";
|
|
11
|
+
import { useHandleComplete } from "./useHandleComplete.js";
|
|
12
|
+
export function CreateView(props) {
|
|
13
|
+
const { mode, editId, initial, selectedTaskId, selectedTaskName, tasks, currentProjectId, onCancel, onDone, onChooseTask, } = props;
|
|
14
|
+
const loopService = useInject(TYPES.LoopService);
|
|
15
|
+
const [taskPickerOpen, setTaskPickerOpen] = useState(false);
|
|
16
|
+
const [openSelect, setOpenSelect] = useState(null);
|
|
17
|
+
const [commandEditorOpen, setCommandEditorOpen] = useState(false);
|
|
18
|
+
const [contextEditorOpen, setContextEditorOpen] = useState(false);
|
|
19
|
+
const [commandValue, setCommandValue] = useState(initial.command ?? "");
|
|
20
|
+
const [contextValue, setContextValue] = useState(initial.context ?? "");
|
|
21
|
+
const fieldCallbacksRef = useRef({});
|
|
22
|
+
const taskModeInitial = initial.taskMode === "existing" ? "Existing task" : "Inline command";
|
|
23
|
+
const resolvedTaskName = useMemo(() => {
|
|
24
|
+
const tid = selectedTaskId ?? initial.taskId;
|
|
25
|
+
if (!tid)
|
|
26
|
+
return null;
|
|
27
|
+
const displayName = selectedTaskName ?? tasks.find((t) => t.id === tid)?.name;
|
|
28
|
+
if (displayName)
|
|
29
|
+
return `${displayName} (${tid.slice(0, 8)})`;
|
|
30
|
+
return `${tid.slice(0, 8)}`;
|
|
31
|
+
}, [selectedTaskName, selectedTaskId, initial.taskId, tasks]);
|
|
32
|
+
const steps = useCreateSteps({
|
|
33
|
+
initial,
|
|
34
|
+
selectedTaskId,
|
|
35
|
+
resolvedTaskName,
|
|
36
|
+
commandValue,
|
|
37
|
+
contextValue,
|
|
38
|
+
projects: props.projects,
|
|
39
|
+
setOpenSelect,
|
|
40
|
+
setTaskPickerOpen,
|
|
41
|
+
setCommandEditorOpen,
|
|
42
|
+
setContextEditorOpen,
|
|
43
|
+
fieldCallbacksRef,
|
|
44
|
+
taskModeInitial,
|
|
45
|
+
});
|
|
46
|
+
const handleComplete = useHandleComplete({
|
|
47
|
+
selectedTaskId,
|
|
48
|
+
mode,
|
|
49
|
+
editId,
|
|
50
|
+
currentProjectId,
|
|
51
|
+
onDone,
|
|
52
|
+
commandValue,
|
|
53
|
+
projects: props.projects,
|
|
54
|
+
loopService,
|
|
55
|
+
});
|
|
56
|
+
const selectOptionsFor = useCallback((field) => {
|
|
57
|
+
if (field === "taskMode") {
|
|
58
|
+
return [t("wizard.taskModeInline"), t("wizard.taskModeExisting")].map((v) => ({ value: v, label: v }));
|
|
59
|
+
}
|
|
60
|
+
if (field === "runNow") {
|
|
61
|
+
return [t("wizard.runNowWait"), t("wizard.runNowNow")].map((v) => ({ value: v, label: v }));
|
|
62
|
+
}
|
|
63
|
+
return props.projects.map((p) => ({ value: p.name, label: p.name }));
|
|
64
|
+
}, [props.projects]);
|
|
65
|
+
const selectTitleFor = (field) => field === "taskMode" ? t("wizard.taskModePrompt")
|
|
66
|
+
: field === "runNow" ? t("wizard.runNowPrompt")
|
|
67
|
+
: t("wizard.projectPrompt");
|
|
68
|
+
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen || contextEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
|
|
69
|
+
onChooseTask({ id: task.id, name: task.name });
|
|
70
|
+
setTaskPickerOpen(false);
|
|
71
|
+
}, onClose: () => setTaskPickerOpen(false) })) : null, openSelect ? (_jsx(SelectModal, { title: selectTitleFor(openSelect), options: selectOptionsFor(openSelect), initialValue: fieldCallbacksRef.current[openSelect]?.value, onSelect: (option) => {
|
|
72
|
+
fieldCallbacksRef.current[openSelect]?.onChange(option.value);
|
|
73
|
+
fieldCallbacksRef.current[openSelect]?.onAdvance();
|
|
74
|
+
setOpenSelect(null);
|
|
75
|
+
}, onClose: () => setOpenSelect(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
|
|
76
|
+
setCommandValue(v);
|
|
77
|
+
setCommandEditorOpen(false);
|
|
78
|
+
}, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
|
|
79
|
+
setContextValue(v);
|
|
80
|
+
setContextEditorOpen(false);
|
|
81
|
+
}, onCancel: () => setContextEditorOpen(false) })) : null] }));
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { darkTheme as theme } from "../../shared/ui/theme.js";
|
|
4
|
+
export function TextField({ value, hint, isActive, }) {
|
|
5
|
+
const borderColor = isActive ? theme.accent.brand : theme.border.dim;
|
|
6
|
+
const backgroundColor = isActive ? theme.bg.input : undefined;
|
|
7
|
+
const showHint = value.length === 0;
|
|
8
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: borderColor, backgroundColor: backgroundColor, paddingLeft: 1, overflow: "hidden", width: "100%", children: [_jsx(Text, { color: showHint ? theme.text.muted : theme.text.primary, children: showHint ? hint : value }), isActive ? _jsx(Text, { inverse: true, children: " " }) : null] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { darkTheme as theme } from "../../shared/ui/theme.js";
|
|
5
|
+
import { t } from "../../shared/i18n/index.js";
|
|
6
|
+
import { useLoopFormValidation } from "../../shared/hooks/useLoopFormValidation.js";
|
|
7
|
+
import { copyToClipboard } from "../../shared/clipboard.js";
|
|
8
|
+
import { sanitizePaste } from "../../shared/utils/paste.js";
|
|
9
|
+
import { TextField } from "./TextField.js";
|
|
10
|
+
export function WizardForm(props) {
|
|
11
|
+
const { title, steps, onComplete, onCancel, disabled = false } = props;
|
|
12
|
+
const [values, setValues] = useState({});
|
|
13
|
+
const [activeField, setActiveField] = useState(0);
|
|
14
|
+
const [validationErrors, setValidationErrors] = useState({});
|
|
15
|
+
const { validateField } = useLoopFormValidation();
|
|
16
|
+
const step = steps[activeField];
|
|
17
|
+
const valueFor = useCallback((s) => values[s.key] ?? s.defaultValue ?? "", [values]);
|
|
18
|
+
// Resolved values: raw values with defaults filled in.
|
|
19
|
+
// skip() functions must receive these so they work before the user touches a field.
|
|
20
|
+
const resolvedValues = useMemo(() => {
|
|
21
|
+
const result = {};
|
|
22
|
+
for (const s of steps)
|
|
23
|
+
result[s.key] = values[s.key] ?? s.defaultValue ?? "";
|
|
24
|
+
return result;
|
|
25
|
+
}, [steps, values]);
|
|
26
|
+
// Synchronous mirror of resolvedValues updated inside setValue so
|
|
27
|
+
// findNextField sees the new value immediately, before React re-renders.
|
|
28
|
+
// Without this, onChange("Existing task") + onAdvance() run in the same
|
|
29
|
+
// tick: setValues queues a state update, but findNextField reads stale
|
|
30
|
+
// resolvedValues where taskMode is still "" → taskId is wrongly skipped.
|
|
31
|
+
const computeResolved = (raw) => {
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const s of steps)
|
|
34
|
+
result[s.key] = raw[s.key] ?? s.defaultValue ?? "";
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
const resolvedValuesRef = useRef(computeResolved({}));
|
|
38
|
+
const setValue = useCallback((key, next) => {
|
|
39
|
+
resolvedValuesRef.current = computeResolved({ ...resolvedValuesRef.current, [key]: next });
|
|
40
|
+
setValues((prev) => ({ ...prev, [key]: next }));
|
|
41
|
+
}, [steps]);
|
|
42
|
+
const clearError = useCallback((key) => {
|
|
43
|
+
setValidationErrors((prev) => {
|
|
44
|
+
if (!(key in prev))
|
|
45
|
+
return prev;
|
|
46
|
+
const next = { ...prev };
|
|
47
|
+
delete next[key];
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
}, []);
|
|
51
|
+
const setError = useCallback((key, msg) => {
|
|
52
|
+
setValidationErrors((prev) => ({ ...prev, [key]: msg }));
|
|
53
|
+
}, []);
|
|
54
|
+
const submit = useCallback(() => {
|
|
55
|
+
const errors = {};
|
|
56
|
+
for (const s of steps) {
|
|
57
|
+
if (s.skip && s.skip(resolvedValues))
|
|
58
|
+
continue;
|
|
59
|
+
const err = validateField(s.key, resolvedValues);
|
|
60
|
+
if (err)
|
|
61
|
+
errors[s.key] = err;
|
|
62
|
+
if (s.validate && !s.validate(valueFor(s))) {
|
|
63
|
+
errors[s.key] = s.validationError ?? t("wizard.validationError");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(errors).length > 0) {
|
|
67
|
+
setValidationErrors(errors);
|
|
68
|
+
const firstErrorIdx = steps.findIndex((s) => s.key in errors);
|
|
69
|
+
if (firstErrorIdx >= 0)
|
|
70
|
+
setActiveField(firstErrorIdx);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const missing = steps.find((s) => !s.skip?.(values) && s.required && !valueFor(s).trim());
|
|
74
|
+
if (missing) {
|
|
75
|
+
setActiveField(steps.indexOf(missing));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const result = {};
|
|
79
|
+
for (const s of steps) {
|
|
80
|
+
if (s.skip && s.skip(resolvedValues))
|
|
81
|
+
continue;
|
|
82
|
+
result[s.key] = valueFor(s);
|
|
83
|
+
}
|
|
84
|
+
onComplete(result);
|
|
85
|
+
}, [steps, valueFor, onComplete, resolvedValues]);
|
|
86
|
+
const findNextField = useCallback((from, delta) => {
|
|
87
|
+
let next = from + delta;
|
|
88
|
+
const len = steps.length;
|
|
89
|
+
const vals = resolvedValuesRef.current;
|
|
90
|
+
while (next >= 0 && next < len) {
|
|
91
|
+
const candidate = steps[next];
|
|
92
|
+
if (!candidate.skip || !candidate.skip(vals))
|
|
93
|
+
return next;
|
|
94
|
+
next += delta;
|
|
95
|
+
}
|
|
96
|
+
return from;
|
|
97
|
+
}, [steps]);
|
|
98
|
+
const moveField = useCallback((delta) => {
|
|
99
|
+
if (step) {
|
|
100
|
+
const err = validateField(step.key, resolvedValues);
|
|
101
|
+
if (err) {
|
|
102
|
+
setError(step.key, err);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (step.validate) {
|
|
106
|
+
const value = valueFor(step);
|
|
107
|
+
if (!step.validate(value)) {
|
|
108
|
+
setError(step.key, step.validationError ?? t("wizard.validationError"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
clearError(step.key);
|
|
113
|
+
}
|
|
114
|
+
const next = findNextField(activeField, delta);
|
|
115
|
+
if (next !== activeField)
|
|
116
|
+
setActiveField(next);
|
|
117
|
+
}, [step, valueFor, resolvedValues, activeField, findNextField, setError, clearError]);
|
|
118
|
+
const visibleSteps = steps.filter((s) => !s.skip || !s.skip(resolvedValues));
|
|
119
|
+
useInput((input, key) => {
|
|
120
|
+
if (input.includes("[<"))
|
|
121
|
+
return;
|
|
122
|
+
if (key.escape) {
|
|
123
|
+
onCancel();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (key.ctrl && input === "s") {
|
|
127
|
+
submit();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (key.ctrl && input === "y") {
|
|
131
|
+
if (step) {
|
|
132
|
+
copyToClipboard(valueFor(step));
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (key.tab) {
|
|
137
|
+
moveField(key.shift ? -1 : 1);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!step)
|
|
141
|
+
return;
|
|
142
|
+
if (step.renderCustom) {
|
|
143
|
+
// If onActivate is set, this is a "launch modal" field:
|
|
144
|
+
// Enter opens the modal, everything else is blocked.
|
|
145
|
+
if (step.onActivate) {
|
|
146
|
+
if (key.return)
|
|
147
|
+
step.onActivate();
|
|
148
|
+
}
|
|
149
|
+
// If no onActivate, the renderCustom component owns its own useInput
|
|
150
|
+
// for text editing (Enter, arrows, backspace). WizardForm only handles
|
|
151
|
+
// Esc / Ctrl+S / Tab which are already caught above.
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Text field editing
|
|
155
|
+
if (key.return) {
|
|
156
|
+
moveField(1);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (key.delete || key.backspace) {
|
|
160
|
+
setValue(step.key, valueFor(step).slice(0, -1));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
|
|
164
|
+
if (input.includes("\x1b[200~")) {
|
|
165
|
+
setValue(step.key, valueFor(step) + sanitizePaste(input));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Multi-char containing CR/LF with no bracketed markers ignore
|
|
169
|
+
if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
|
|
170
|
+
return;
|
|
171
|
+
// Multi-char printable input = unbracketed single-line paste (e.g. right-click)
|
|
172
|
+
if (input.length > 1 && !key.meta) {
|
|
173
|
+
setValue(step.key, valueFor(step) + sanitizePaste(input));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (input.length === 1 && input >= " " && input <= "~") {
|
|
177
|
+
setValue(step.key, valueFor(step) + input);
|
|
178
|
+
}
|
|
179
|
+
}, { isActive: !disabled });
|
|
180
|
+
const mid = Math.ceil(steps.length / 2);
|
|
181
|
+
const columns = [
|
|
182
|
+
[steps.slice(0, mid), 0],
|
|
183
|
+
[steps.slice(mid), mid],
|
|
184
|
+
];
|
|
185
|
+
const filledCount = visibleSteps.filter((s) => valueFor(s).trim().length > 0).length;
|
|
186
|
+
function renderField(s, fieldIdx) {
|
|
187
|
+
const isActive = fieldIdx === activeField;
|
|
188
|
+
const val = valueFor(s);
|
|
189
|
+
if (s.skip && s.skip(resolvedValues))
|
|
190
|
+
return null;
|
|
191
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: isActive ? theme.accent.brand : theme.text.muted, bold: true, children: isActive ? "\u203a " : " " }), _jsx(Text, { color: isActive ? theme.accent.brand : theme.text.secondary, bold: true, children: s.prompt }), s.required ? _jsx(Text, { color: theme.semantic.danger, children: "*" }) : null] }), _jsx(Box, { paddingLeft: 2, width: "100%", children: s.renderCustom ? (s.renderCustom({
|
|
192
|
+
value: val,
|
|
193
|
+
isActive,
|
|
194
|
+
onChange: (next) => setValue(s.key, next),
|
|
195
|
+
onAdvance: () => moveField(1),
|
|
196
|
+
})) : (_jsx(TextField, { value: val, hint: s.hint, isActive: isActive })) }), validationErrors[s.key] ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: theme.semantic.danger, children: validationErrors[s.key] }) })) : null] }, s.key));
|
|
197
|
+
}
|
|
198
|
+
return (_jsxs(Box, { flexDirection: "column", backgroundColor: theme.bg.surface, paddingY: 1, paddingX: 2, flexGrow: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { color: theme.accent.loop, bold: true, children: title }), _jsxs(Text, { color: theme.text.muted, children: [t("wizard.filled", { filled: filledCount, total: visibleSteps.length }), " . ", t("wizard.footerHints")] })] }), _jsx(Box, { flexDirection: "row", gap: 4, children: columns.map(([col, offset]) => (_jsx(Box, { flexDirection: "column", width: "50%", children: col.map((s, i) => renderField(s, i + offset)) }, offset))) })] }));
|
|
199
|
+
}
|