loop-task 1.5.5 → 2.0.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/README.md +127 -21
- package/dist/board/App.js +1 -1
- package/dist/board/components/ActionButtons.js +1 -1
- package/dist/board/components/BoardButton.js +1 -1
- package/dist/board/components/ConfirmModal.js +1 -1
- package/dist/board/components/ContextHelpModal.js +1 -1
- package/dist/board/components/CreateForm.js +1 -1
- package/dist/board/components/CreateProjectModal.js +1 -1
- package/dist/board/components/DeleteProjectConfirm.js +1 -1
- package/dist/board/components/DetailView.js +1 -1
- package/dist/board/components/EditProjectModal.js +1 -1
- package/dist/board/components/FilterBar.js +1 -1
- package/dist/board/components/Footer.js +1 -1
- package/dist/board/components/Header.js +1 -1
- package/dist/board/components/HelpModal.js +1 -1
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +1 -1
- package/dist/board/components/Navigator.js +1 -1
- package/dist/board/components/ProjectsModal.js +1 -1
- package/dist/board/components/ProjectsPage.js +1 -1
- package/dist/board/components/RunHistory.js +1 -1
- package/dist/board/components/SearchBox.js +1 -1
- package/dist/board/components/SearchSelect.js +1 -1
- package/dist/board/components/TaskBrowser.js +1 -1
- package/dist/board/components/TaskFilterBar.js +1 -1
- package/dist/board/components/TaskForm.js +1 -1
- package/dist/board/components/Timeline.js +1 -1
- package/dist/board/focus-context.js +1 -1
- package/dist/board/format.js +15 -6
- package/dist/board/index.js +1 -1
- package/dist/board/toast.js +1 -1
- package/dist/cli/import-validator.js +151 -0
- package/dist/cli/import-writer.js +77 -0
- package/dist/cli.js +111 -17
- package/dist/config/constants.js +52 -0
- package/dist/daemon/file-watcher.js +171 -0
- package/dist/daemon/http-server.js +605 -0
- package/dist/daemon/index.js +36 -6
- package/dist/daemon/manager.js +94 -13
- package/dist/daemon/projects.js +10 -0
- package/dist/daemon/server.js +21 -0
- package/dist/daemon/task-manager.js +7 -0
- package/dist/esm-loader.js +4 -1
- package/dist/hooks/useLoopFormValidation.js +140 -0
- package/dist/i18n/en.json +237 -17
- package/dist/shared/fs-utils.js +34 -1
- package/dist/tui/app.js +675 -151
- package/dist/tui/commands.js +114 -0
- package/dist/tui/components/ActionButtons.js +49 -0
- package/dist/tui/components/Button.js +18 -0
- package/dist/tui/components/ChainEditor.js +112 -0
- package/dist/tui/components/CommandBuilderField.js +143 -0
- package/dist/tui/components/CommandEditorModal.js +159 -0
- package/dist/tui/components/CommandInput.js +303 -0
- package/dist/tui/components/CommandsBrowserModal.js +77 -0
- package/dist/tui/components/ConfirmModal.js +9 -0
- package/dist/tui/components/ContextHelpModal.js +8 -0
- package/dist/tui/components/CreateForm.js +223 -0
- package/dist/tui/components/DebugPanel.js +8 -0
- package/dist/tui/components/ExportModal.js +37 -0
- package/dist/tui/components/FilterBar.js +13 -0
- package/dist/tui/components/FocusableButton.js +16 -0
- package/dist/tui/components/FocusableInput.js +69 -0
- package/dist/tui/components/FocusableList.js +46 -0
- package/dist/tui/components/FocusableSearchSelect.js +82 -0
- package/dist/tui/components/Footer.js +42 -0
- package/dist/tui/components/Header.js +40 -0
- package/dist/tui/components/HelpGuideModal.js +21 -0
- package/dist/tui/components/HelpModal.js +59 -0
- package/dist/tui/components/InlineCommandEditor.js +143 -0
- package/dist/tui/components/Inspector.js +31 -0
- package/dist/tui/components/LeftPanel.js +33 -0
- package/dist/tui/components/LogModal.js +121 -0
- package/dist/tui/components/Modal.js +11 -0
- package/dist/tui/components/Navigator.js +68 -0
- package/dist/tui/components/PatchEditForm.js +78 -0
- package/dist/tui/components/ProjectForm.js +64 -0
- package/dist/tui/components/ProjectsModal.js +40 -0
- package/dist/tui/components/ProjectsPage.js +99 -0
- package/dist/tui/components/RightPanel.js +34 -0
- package/dist/tui/components/RunHistory.js +108 -0
- package/dist/tui/components/SearchSelect.js +67 -0
- package/dist/tui/components/SelectModal.js +76 -0
- package/dist/tui/components/TabBar.js +20 -0
- package/dist/tui/components/TaskBrowser.js +96 -0
- package/dist/tui/components/TaskFilterBar.js +9 -0
- package/dist/tui/components/TaskForm.js +134 -0
- package/dist/tui/components/TaskPickerModal.js +60 -0
- package/dist/tui/components/Toast.js +37 -0
- package/dist/tui/components/WelcomeScreen.js +70 -0
- package/dist/tui/components/WizardForm.js +178 -0
- package/dist/tui/daemon.js +142 -0
- package/dist/tui/format.js +111 -0
- package/dist/tui/hooks/useBreakpoint.js +7 -0
- package/dist/tui/hooks/useHoverState.js +11 -0
- package/dist/tui/hooks/useLogStream.js +32 -0
- package/dist/tui/hooks/useLoopPolling.js +28 -0
- package/dist/tui/index.js +24 -0
- package/dist/tui/router.js +16 -0
- package/dist/tui/state.js +175 -0
- package/dist/tui/theme.js +96 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/utils/paste.js +10 -0
- package/dist/tui/utils/validation.js +56 -0
- package/package.json +13 -8
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { darkTheme as theme } from "../theme.js";
|
|
5
|
+
import { createLoop } from "../daemon.js";
|
|
6
|
+
import { parseDuration } from "../../duration.js";
|
|
7
|
+
const EXAMPLES = [
|
|
8
|
+
{ label: "Run tests every 30m", interval: "30m", command: "npm", args: ["test"] },
|
|
9
|
+
{ label: "Watch for GitHub issues every 5m", interval: "5m", command: "gh", args: ["issue", "list"] },
|
|
10
|
+
{ label: "Sync deps every hour", interval: "1h", command: "npm", args: ["outdated"] },
|
|
11
|
+
];
|
|
12
|
+
export function WelcomeScreen(props) {
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
14
|
+
const [creating, setCreating] = React.useState(null);
|
|
15
|
+
const items = [...EXAMPLES.map((e) => e.label), "Create empty loop"];
|
|
16
|
+
useInput((_input, key) => {
|
|
17
|
+
if (creating)
|
|
18
|
+
return;
|
|
19
|
+
if (key.upArrow) {
|
|
20
|
+
setSelectedIndex((i) => i <= 0 ? items.length - 1 : i - 1);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (key.downArrow) {
|
|
24
|
+
setSelectedIndex((i) => i >= items.length - 1 ? 0 : i + 1);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
if (selectedIndex === EXAMPLES.length) {
|
|
29
|
+
props.onCreateEmpty();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const example = EXAMPLES[selectedIndex];
|
|
33
|
+
if (example) {
|
|
34
|
+
setCreating(example.label);
|
|
35
|
+
const options = {
|
|
36
|
+
interval: parseDuration(example.interval),
|
|
37
|
+
taskId: null,
|
|
38
|
+
command: example.command,
|
|
39
|
+
commandArgs: example.args,
|
|
40
|
+
cwd: "",
|
|
41
|
+
immediate: false,
|
|
42
|
+
maxRuns: null,
|
|
43
|
+
verbose: false,
|
|
44
|
+
description: example.label,
|
|
45
|
+
projectId: "default",
|
|
46
|
+
offset: null,
|
|
47
|
+
};
|
|
48
|
+
createLoop(options, example.interval)
|
|
49
|
+
.then(() => props.onRefresh())
|
|
50
|
+
.then(() => props.onCreateLoop())
|
|
51
|
+
.catch(() => setCreating(null));
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (_input === "n") {
|
|
56
|
+
props.onCreateEmpty();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (_input === "h") {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return (_jsx(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: theme.accent.brand, backgroundColor: theme.bg.surface, paddingX: 4, paddingY: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.accent.brand, bold: true, children: ["\n", "Welcome to loop-task!", "\n"] }), _jsx(Text, { color: theme.text.secondary, children: "Loop engineering: define a purpose," }), _jsxs(Text, { color: theme.text.secondary, children: ["give it an interval, let it iterate.", "\n"] }), _jsxs(Text, { color: theme.text.muted, children: ["No loops yet. Here are some examples:", "\n"] }), items.map((label, i) => {
|
|
64
|
+
const isSelected = i === selectedIndex;
|
|
65
|
+
const bg = isSelected ? theme.bg.active : undefined;
|
|
66
|
+
const fg = isSelected ? theme.text.inverse : theme.text.primary;
|
|
67
|
+
const prefix = isSelected ? "\u203a " : " ";
|
|
68
|
+
return (_jsx(Box, { backgroundColor: bg, children: _jsxs(Text, { color: fg, children: [prefix, label] }) }, label));
|
|
69
|
+
}), _jsxs(Text, { color: theme.text.muted, children: ["\n", "or press 'n' to create a custom loop"] }), _jsx(Text, { color: theme.text.muted, children: "press 'h' for keyboard shortcuts (from main view)" }), creating ? (_jsxs(Text, { color: theme.accent.brand, children: ["\n", "Creating: ", creating, "..."] })) : null] }) }));
|
|
70
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useMemo } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { darkTheme as theme } from "../theme.js";
|
|
5
|
+
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { useLoopFormValidation } from "../../hooks/useLoopFormValidation.js";
|
|
7
|
+
import { copyToClipboard } from "../../shared/clipboard.js";
|
|
8
|
+
import { sanitizePaste } from "../utils/paste.js";
|
|
9
|
+
function TextField({ value, hint, isActive, }) {
|
|
10
|
+
const borderColor = isActive ? theme.accent.brand : theme.border.dim;
|
|
11
|
+
const backgroundColor = isActive ? theme.bg.input : undefined;
|
|
12
|
+
const showHint = value.length === 0;
|
|
13
|
+
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] }));
|
|
14
|
+
}
|
|
15
|
+
export function WizardForm(props) {
|
|
16
|
+
const { title, steps, onComplete, onCancel, disabled = false } = props;
|
|
17
|
+
const [values, setValues] = useState({});
|
|
18
|
+
const [activeField, setActiveField] = useState(0);
|
|
19
|
+
const [validationErrors, setValidationErrors] = useState({});
|
|
20
|
+
const { validateField } = useLoopFormValidation();
|
|
21
|
+
const step = steps[activeField];
|
|
22
|
+
const valueFor = useCallback((s) => values[s.key] ?? s.defaultValue ?? "", [values]);
|
|
23
|
+
// Resolved values: raw values with defaults filled in.
|
|
24
|
+
// skip() functions must receive these so they work before the user touches a field.
|
|
25
|
+
const resolvedValues = useMemo(() => {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const s of steps)
|
|
28
|
+
result[s.key] = values[s.key] ?? s.defaultValue ?? "";
|
|
29
|
+
return result;
|
|
30
|
+
}, [steps, values]);
|
|
31
|
+
const setValue = useCallback((key, next) => {
|
|
32
|
+
setValues((prev) => ({ ...prev, [key]: next }));
|
|
33
|
+
}, []);
|
|
34
|
+
const clearError = useCallback((key) => {
|
|
35
|
+
setValidationErrors((prev) => {
|
|
36
|
+
if (!(key in prev))
|
|
37
|
+
return prev;
|
|
38
|
+
const next = { ...prev };
|
|
39
|
+
delete next[key];
|
|
40
|
+
return next;
|
|
41
|
+
});
|
|
42
|
+
}, []);
|
|
43
|
+
const setError = useCallback((key, msg) => {
|
|
44
|
+
setValidationErrors((prev) => ({ ...prev, [key]: msg }));
|
|
45
|
+
}, []);
|
|
46
|
+
const submit = useCallback(() => {
|
|
47
|
+
const errors = {};
|
|
48
|
+
for (const s of steps) {
|
|
49
|
+
if (s.skip && s.skip(resolvedValues))
|
|
50
|
+
continue;
|
|
51
|
+
const err = validateField(s.key, resolvedValues);
|
|
52
|
+
if (err)
|
|
53
|
+
errors[s.key] = err;
|
|
54
|
+
}
|
|
55
|
+
if (Object.keys(errors).length > 0) {
|
|
56
|
+
setValidationErrors(errors);
|
|
57
|
+
const firstErrorIdx = steps.findIndex((s) => s.key in errors);
|
|
58
|
+
if (firstErrorIdx >= 0)
|
|
59
|
+
setActiveField(firstErrorIdx);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const missing = steps.find((s) => !s.skip?.(values) && s.required && !valueFor(s).trim());
|
|
63
|
+
if (missing) {
|
|
64
|
+
setActiveField(steps.indexOf(missing));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const result = {};
|
|
68
|
+
for (const s of steps) {
|
|
69
|
+
if (s.skip && s.skip(resolvedValues))
|
|
70
|
+
continue;
|
|
71
|
+
result[s.key] = valueFor(s);
|
|
72
|
+
}
|
|
73
|
+
onComplete(result);
|
|
74
|
+
}, [steps, valueFor, onComplete, resolvedValues]);
|
|
75
|
+
const findNextField = useCallback((from, delta) => {
|
|
76
|
+
let next = from + delta;
|
|
77
|
+
const len = steps.length;
|
|
78
|
+
while (next >= 0 && next < len) {
|
|
79
|
+
const candidate = steps[next];
|
|
80
|
+
if (!candidate.skip || !candidate.skip(resolvedValues))
|
|
81
|
+
return next;
|
|
82
|
+
next += delta;
|
|
83
|
+
}
|
|
84
|
+
return from;
|
|
85
|
+
}, [steps, values]);
|
|
86
|
+
const moveField = useCallback((delta) => {
|
|
87
|
+
if (step) {
|
|
88
|
+
const err = validateField(step.key, resolvedValues);
|
|
89
|
+
if (err) {
|
|
90
|
+
setError(step.key, err);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
clearError(step.key);
|
|
94
|
+
}
|
|
95
|
+
const next = findNextField(activeField, delta);
|
|
96
|
+
if (next !== activeField)
|
|
97
|
+
setActiveField(next);
|
|
98
|
+
}, [step, valueFor, resolvedValues, activeField, findNextField, setError, clearError]);
|
|
99
|
+
const visibleSteps = steps.filter((s) => !s.skip || !s.skip(resolvedValues));
|
|
100
|
+
useInput((input, key) => {
|
|
101
|
+
if (key.escape) {
|
|
102
|
+
onCancel();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.ctrl && input === "s") {
|
|
106
|
+
submit();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (key.ctrl && input === "y") {
|
|
110
|
+
if (step) {
|
|
111
|
+
copyToClipboard(valueFor(step));
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (key.tab) {
|
|
116
|
+
moveField(key.shift ? -1 : 1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!step)
|
|
120
|
+
return;
|
|
121
|
+
if (step.renderCustom) {
|
|
122
|
+
// If onActivate is set, this is a "launch modal" field:
|
|
123
|
+
// Enter opens the modal, everything else is blocked.
|
|
124
|
+
if (step.onActivate) {
|
|
125
|
+
if (key.return)
|
|
126
|
+
step.onActivate();
|
|
127
|
+
}
|
|
128
|
+
// If no onActivate, the renderCustom component owns its own useInput
|
|
129
|
+
// for text editing (Enter, arrows, backspace). WizardForm only handles
|
|
130
|
+
// Esc / Ctrl+S / Tab which are already caught above.
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Text field editing
|
|
134
|
+
if (key.return) {
|
|
135
|
+
moveField(1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (key.delete || key.backspace) {
|
|
139
|
+
setValue(step.key, valueFor(step).slice(0, -1));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
|
|
143
|
+
if (input.includes("\x1b[200~")) {
|
|
144
|
+
setValue(step.key, valueFor(step) + sanitizePaste(input));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Multi-char containing CR/LF with no bracketed markers — ignore
|
|
148
|
+
if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
|
|
149
|
+
return;
|
|
150
|
+
// Multi-char printable input = unbracketed single-line paste (e.g. right-click)
|
|
151
|
+
if (input.length > 1 && !key.meta) {
|
|
152
|
+
setValue(step.key, valueFor(step) + sanitizePaste(input));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (input.length === 1 && input >= " " && input <= "~") {
|
|
156
|
+
setValue(step.key, valueFor(step) + input);
|
|
157
|
+
}
|
|
158
|
+
}, { isActive: !disabled });
|
|
159
|
+
const mid = Math.ceil(steps.length / 2);
|
|
160
|
+
const columns = [
|
|
161
|
+
[steps.slice(0, mid), 0],
|
|
162
|
+
[steps.slice(mid), mid],
|
|
163
|
+
];
|
|
164
|
+
const filledCount = visibleSteps.filter((s) => valueFor(s).trim().length > 0).length;
|
|
165
|
+
function renderField(s, fieldIdx) {
|
|
166
|
+
const isActive = fieldIdx === activeField;
|
|
167
|
+
const val = valueFor(s);
|
|
168
|
+
if (s.skip && s.skip(resolvedValues))
|
|
169
|
+
return null;
|
|
170
|
+
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({
|
|
171
|
+
value: val,
|
|
172
|
+
isActive,
|
|
173
|
+
onChange: (next) => setValue(s.key, next),
|
|
174
|
+
onAdvance: () => moveField(1),
|
|
175
|
+
})) : (_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));
|
|
176
|
+
}
|
|
177
|
+
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))) })] }));
|
|
178
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { sendRequest, streamRequest } from "../client/ipc.js";
|
|
2
|
+
import { t } from "../i18n/index.js";
|
|
3
|
+
import { LOG_TAIL_DEFAULT } from "../config/constants.js";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { getDataDir } from "../config/paths.js";
|
|
7
|
+
function expectOk(message, type) {
|
|
8
|
+
if (type !== "ok") {
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function listLoops() {
|
|
13
|
+
const response = await sendRequest({ type: "list" });
|
|
14
|
+
if (response.type !== "ok") {
|
|
15
|
+
throw new Error(response.message);
|
|
16
|
+
}
|
|
17
|
+
return response.data;
|
|
18
|
+
}
|
|
19
|
+
export async function pauseLoop(id) {
|
|
20
|
+
const response = await sendRequest({ type: "pause", payload: { id } });
|
|
21
|
+
expectOk(response.message ?? t("errors.pauseFailed"), response.type);
|
|
22
|
+
}
|
|
23
|
+
export async function resumeLoop(id) {
|
|
24
|
+
const response = await sendRequest({ type: "resume", payload: { id } });
|
|
25
|
+
expectOk(response.message ?? t("errors.resumeFailed"), response.type);
|
|
26
|
+
}
|
|
27
|
+
export async function stopLoop(id) {
|
|
28
|
+
const response = await sendRequest({ type: "stop-loop", payload: { id } });
|
|
29
|
+
expectOk(response.message ?? "Stop failed", response.type);
|
|
30
|
+
}
|
|
31
|
+
export async function playLoop(id) {
|
|
32
|
+
const response = await sendRequest({ type: "play-loop", payload: { id } });
|
|
33
|
+
expectOk(response.message ?? "Play failed", response.type);
|
|
34
|
+
}
|
|
35
|
+
export async function triggerLoop(id) {
|
|
36
|
+
const response = await sendRequest({ type: "trigger", payload: { id } });
|
|
37
|
+
expectOk(response.message ?? t("errors.forceRunFailed"), response.type);
|
|
38
|
+
}
|
|
39
|
+
export async function deleteLoop(id) {
|
|
40
|
+
const response = await sendRequest({ type: "delete", payload: { id } });
|
|
41
|
+
expectOk(response.message ?? t("errors.deleteFailed"), response.type);
|
|
42
|
+
}
|
|
43
|
+
export async function createLoop(options, intervalHuman) {
|
|
44
|
+
const response = await sendRequest({
|
|
45
|
+
type: "start",
|
|
46
|
+
payload: { ...options, intervalHuman },
|
|
47
|
+
});
|
|
48
|
+
if (response.type !== "ok") {
|
|
49
|
+
throw new Error(response.message);
|
|
50
|
+
}
|
|
51
|
+
return response.data.id;
|
|
52
|
+
}
|
|
53
|
+
export async function updateLoop(id, options, intervalHuman) {
|
|
54
|
+
const response = await sendRequest({
|
|
55
|
+
type: "update",
|
|
56
|
+
payload: { id, ...options, intervalHuman },
|
|
57
|
+
});
|
|
58
|
+
if (response.type !== "ok") {
|
|
59
|
+
throw new Error(response.message);
|
|
60
|
+
}
|
|
61
|
+
return response.data.id;
|
|
62
|
+
}
|
|
63
|
+
export function streamLogs(id, onLine, onError) {
|
|
64
|
+
return streamRequest({ type: "logs", payload: { id, follow: true, tail: LOG_TAIL_DEFAULT } }, onLine, () => { }, onError);
|
|
65
|
+
}
|
|
66
|
+
export async function fetchRunLog(id, runNumber) {
|
|
67
|
+
const response = await sendRequest({ type: "run-log", payload: { id, runNumber } });
|
|
68
|
+
if (response.type !== "ok") {
|
|
69
|
+
throw new Error(response.message ?? "Failed to fetch run log");
|
|
70
|
+
}
|
|
71
|
+
return response.data ?? "";
|
|
72
|
+
}
|
|
73
|
+
export function streamRunLog(id, runNumber, onLine, onEnd, onError) {
|
|
74
|
+
return streamRequest({ type: "run-log-stream", payload: { id, runNumber } }, onLine, onEnd, onError);
|
|
75
|
+
}
|
|
76
|
+
export async function listTasks() {
|
|
77
|
+
const response = await sendRequest({ type: "task-list" });
|
|
78
|
+
if (response.type !== "ok") {
|
|
79
|
+
throw new Error(response.message);
|
|
80
|
+
}
|
|
81
|
+
return response.data;
|
|
82
|
+
}
|
|
83
|
+
export async function createTask(payload) {
|
|
84
|
+
const response = await sendRequest({ type: "task-create", payload });
|
|
85
|
+
if (response.type !== "ok") {
|
|
86
|
+
throw new Error(response.message);
|
|
87
|
+
}
|
|
88
|
+
return response.data;
|
|
89
|
+
}
|
|
90
|
+
export async function updateTask(id, payload) {
|
|
91
|
+
const response = await sendRequest({ type: "task-update", payload: { id, ...payload } });
|
|
92
|
+
if (response.type !== "ok") {
|
|
93
|
+
throw new Error(response.message);
|
|
94
|
+
}
|
|
95
|
+
return response.data;
|
|
96
|
+
}
|
|
97
|
+
export async function deleteTask(id) {
|
|
98
|
+
const response = await sendRequest({ type: "task-delete", payload: { id } });
|
|
99
|
+
expectOk(response.message ?? "Task delete failed", response.type);
|
|
100
|
+
}
|
|
101
|
+
export async function listProjects() {
|
|
102
|
+
const response = await sendRequest({ type: "project-list" });
|
|
103
|
+
if (response.type !== "ok")
|
|
104
|
+
throw new Error(response.message);
|
|
105
|
+
return response.data;
|
|
106
|
+
}
|
|
107
|
+
export async function createProject(name, color) {
|
|
108
|
+
const response = await sendRequest({ type: "project-create", payload: { name, color } });
|
|
109
|
+
if (response.type !== "ok")
|
|
110
|
+
throw new Error(response.message);
|
|
111
|
+
return response.data;
|
|
112
|
+
}
|
|
113
|
+
export async function updateProject(id, name, color) {
|
|
114
|
+
const response = await sendRequest({ type: "project-update", payload: { id, name, color } });
|
|
115
|
+
expectOk(response.message ?? t("project.error.updateFailed"), response.type);
|
|
116
|
+
}
|
|
117
|
+
export async function exportConfig() {
|
|
118
|
+
const [loops, tasks, projects] = await Promise.all([
|
|
119
|
+
listLoops().catch(() => []),
|
|
120
|
+
listTasks().catch(() => []),
|
|
121
|
+
listProjects().catch(() => []),
|
|
122
|
+
]);
|
|
123
|
+
const exportData = {
|
|
124
|
+
version: 2,
|
|
125
|
+
exportedAt: new Date().toISOString(),
|
|
126
|
+
loops,
|
|
127
|
+
tasks,
|
|
128
|
+
projects,
|
|
129
|
+
};
|
|
130
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
131
|
+
const dataDir = getDataDir();
|
|
132
|
+
const exportsDir = path.join(dataDir, "exports");
|
|
133
|
+
await fs.mkdir(exportsDir, { recursive: true });
|
|
134
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
135
|
+
const filePath = path.join(exportsDir, `loop-export-${ts}.json`);
|
|
136
|
+
await fs.writeFile(filePath, json, "utf-8");
|
|
137
|
+
return { json, filePath };
|
|
138
|
+
}
|
|
139
|
+
export async function deleteProject(id) {
|
|
140
|
+
const response = await sendRequest({ type: "project-delete", payload: { id } });
|
|
141
|
+
expectOk(response.message ?? t("project.error.deleteFailed"), response.type);
|
|
142
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { t } from "../i18n/index.js";
|
|
2
|
+
export function unescapeCommand(str) {
|
|
3
|
+
return str
|
|
4
|
+
.replace(/\\\\/g, "\x00")
|
|
5
|
+
.replace(/\\"/g, '"')
|
|
6
|
+
.replace(/\x00/g, "\\");
|
|
7
|
+
}
|
|
8
|
+
export function quoteArg(arg) {
|
|
9
|
+
return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
|
|
10
|
+
}
|
|
11
|
+
export function commandLine(command, args) {
|
|
12
|
+
return [unescapeCommand(command), ...args.map((a) => quoteArg(unescapeCommand(a)))].join(" ").trim();
|
|
13
|
+
}
|
|
14
|
+
export function formatCmd(command, args, max = 24) {
|
|
15
|
+
const full = commandLine(command, args);
|
|
16
|
+
return full.length > max ? full.slice(0, max - 3) + "..." : full;
|
|
17
|
+
}
|
|
18
|
+
export function describeLoop(loop) {
|
|
19
|
+
return loop.description?.trim() || commandLine(loop.command, loop.commandArgs);
|
|
20
|
+
}
|
|
21
|
+
export function truncate(text, max) {
|
|
22
|
+
return text.length > max ? text.slice(0, max - 3) + "..." : text;
|
|
23
|
+
}
|
|
24
|
+
export function timeAgo(iso) {
|
|
25
|
+
if (!iso)
|
|
26
|
+
return t("format.dash");
|
|
27
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
28
|
+
const secs = Math.floor(diff / 1000);
|
|
29
|
+
if (secs < 5)
|
|
30
|
+
return t("format.justNow");
|
|
31
|
+
if (secs < 60)
|
|
32
|
+
return t("format.secsAgo", { secs });
|
|
33
|
+
const mins = Math.floor(secs / 60);
|
|
34
|
+
if (mins < 60)
|
|
35
|
+
return t("format.minsAgo", { mins });
|
|
36
|
+
const hrs = Math.floor(mins / 60);
|
|
37
|
+
if (hrs < 24)
|
|
38
|
+
return t("format.hrsAgo", { hrs });
|
|
39
|
+
return t("format.daysAgo", { days: Math.floor(hrs / 24) });
|
|
40
|
+
}
|
|
41
|
+
export function timeUntil(iso) {
|
|
42
|
+
if (!iso)
|
|
43
|
+
return t("format.dash");
|
|
44
|
+
const diff = Math.max(0, new Date(iso).getTime() - Date.now());
|
|
45
|
+
const secs = Math.floor(diff / 1000);
|
|
46
|
+
if (secs < 5)
|
|
47
|
+
return t("format.justNow");
|
|
48
|
+
if (secs < 60)
|
|
49
|
+
return t("format.secsAhead", { secs });
|
|
50
|
+
const mins = Math.floor(secs / 60);
|
|
51
|
+
if (mins < 60)
|
|
52
|
+
return t("format.minsAhead", { mins });
|
|
53
|
+
const hrs = Math.floor(mins / 60);
|
|
54
|
+
if (hrs < 24)
|
|
55
|
+
return t("format.hrsAhead", { hrs });
|
|
56
|
+
return t("format.daysAhead", { days: Math.floor(hrs / 24) });
|
|
57
|
+
}
|
|
58
|
+
const STATUS_COLORS = {
|
|
59
|
+
running: "#4ade80",
|
|
60
|
+
waiting: "#6b7280",
|
|
61
|
+
paused: "#facc15",
|
|
62
|
+
idle: "#fb923c",
|
|
63
|
+
stopped: "#f87171",
|
|
64
|
+
};
|
|
65
|
+
export function statusColor(status) {
|
|
66
|
+
return STATUS_COLORS[status] ?? "#ffffff";
|
|
67
|
+
}
|
|
68
|
+
export function timingLabel(loop) {
|
|
69
|
+
if (loop.status === "paused")
|
|
70
|
+
return t("format.timingPaused");
|
|
71
|
+
if (loop.status === "idle")
|
|
72
|
+
return t("format.timingIdle");
|
|
73
|
+
if (loop.nextRunAt)
|
|
74
|
+
return t("format.timingNext", { timeAgo: timeUntil(loop.nextRunAt) });
|
|
75
|
+
if (loop.lastRunAt)
|
|
76
|
+
return t("format.timingLast", { timeAgo: timeAgo(loop.lastRunAt) });
|
|
77
|
+
return t("format.timingNew");
|
|
78
|
+
}
|
|
79
|
+
export function statusLabel(status) {
|
|
80
|
+
return status === "waiting" ? "waiting" : status;
|
|
81
|
+
}
|
|
82
|
+
export function formatFileSize(bytes) {
|
|
83
|
+
if (bytes < 1024)
|
|
84
|
+
return `${bytes} B`;
|
|
85
|
+
if (bytes < 1024 * 1024)
|
|
86
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
87
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
88
|
+
}
|
|
89
|
+
export function formatRunDuration(ms) {
|
|
90
|
+
if (ms < 1000)
|
|
91
|
+
return `${ms}ms`;
|
|
92
|
+
if (ms < 60000)
|
|
93
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
94
|
+
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
95
|
+
}
|
|
96
|
+
export function formatRunTime(iso) {
|
|
97
|
+
const d = new Date(iso);
|
|
98
|
+
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
99
|
+
}
|
|
100
|
+
export function formatDate(iso) {
|
|
101
|
+
const d = new Date(iso);
|
|
102
|
+
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
103
|
+
const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
104
|
+
return `${date} ${time}`;
|
|
105
|
+
}
|
|
106
|
+
export function sinceLabel(loop) {
|
|
107
|
+
const ts = loop.sessionStartedAt ?? loop.createdAt;
|
|
108
|
+
if (!ts)
|
|
109
|
+
return t("format.dash");
|
|
110
|
+
return formatDate(ts);
|
|
111
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useStdout } from "ink";
|
|
2
|
+
import { BOARD_BREAKPOINT_WIDTH } from "../../config/constants.js";
|
|
3
|
+
export function useBreakpoint() {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const width = stdout?.columns ?? 80;
|
|
6
|
+
return width < BOARD_BREAKPOINT_WIDTH ? "narrow" : "wide";
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
export function useHoverState() {
|
|
3
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
4
|
+
return {
|
|
5
|
+
isHovered,
|
|
6
|
+
hoverProps: {
|
|
7
|
+
onMouseOver: () => setIsHovered(true),
|
|
8
|
+
onMouseOut: () => setIsHovered(false),
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { streamLogs } from "../daemon.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
import { LOG_LINES_MAX } from "../../config/constants.js";
|
|
5
|
+
export function useLogStream(selectedId, view, onError) {
|
|
6
|
+
const [logLines, setLogLines] = useState([]);
|
|
7
|
+
const logSocket = useRef(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
logSocket.current?.destroy();
|
|
10
|
+
logSocket.current = null;
|
|
11
|
+
setLogLines([]);
|
|
12
|
+
if (!selectedId || view !== "board") {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
setLogLines([t("board.logWaiting")]);
|
|
16
|
+
const socket = streamLogs(selectedId, (line) => setLogLines((prev) => {
|
|
17
|
+
const next = prev[0] === t("board.logWaiting") ? [] : prev;
|
|
18
|
+
return [...next, line].slice(-LOG_LINES_MAX);
|
|
19
|
+
}), onError);
|
|
20
|
+
logSocket.current = socket;
|
|
21
|
+
return () => {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
if (logSocket.current === socket) {
|
|
24
|
+
logSocket.current = null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}, [selectedId, view]);
|
|
28
|
+
return {
|
|
29
|
+
logLines,
|
|
30
|
+
destroy: () => logSocket.current?.destroy(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { listLoops } from "../daemon.js";
|
|
3
|
+
import { POLL_MS } from "../../config/constants.js";
|
|
4
|
+
export function useLoopPolling() {
|
|
5
|
+
const [loops, setLoops] = useState([]);
|
|
6
|
+
const [daemonStatus, setDaemonStatus] = useState("starting");
|
|
7
|
+
const lastSerialized = useRef("");
|
|
8
|
+
const refresh = useCallback(async () => {
|
|
9
|
+
try {
|
|
10
|
+
const next = await listLoops();
|
|
11
|
+
const serialized = JSON.stringify(next);
|
|
12
|
+
if (serialized !== lastSerialized.current) {
|
|
13
|
+
lastSerialized.current = serialized;
|
|
14
|
+
setLoops(next);
|
|
15
|
+
}
|
|
16
|
+
setDaemonStatus("connected");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
setDaemonStatus("error");
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
void refresh();
|
|
24
|
+
const timer = setInterval(() => void refresh(), POLL_MS);
|
|
25
|
+
return () => clearInterval(timer);
|
|
26
|
+
}, [refresh]);
|
|
27
|
+
return { loops, daemonStatus, refresh };
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { App } from "./app.js";
|
|
4
|
+
import { BRACKETED_PASTE_ENABLE, BRACKETED_PASTE_DISABLE } from "../config/constants.js";
|
|
5
|
+
export async function launchBoard() {
|
|
6
|
+
// Enable bracketed paste so a native paste (Ctrl+Shift+V / Cmd+V / right-click)
|
|
7
|
+
// arrives as one delimited chunk instead of a stream of keypresses.
|
|
8
|
+
process.stdout.write(BRACKETED_PASTE_ENABLE);
|
|
9
|
+
const disableBracketedPaste = () => process.stdout.write(BRACKETED_PASTE_DISABLE);
|
|
10
|
+
const instance = render(React.createElement(App, { onQuit: () => {
|
|
11
|
+
disableBracketedPaste();
|
|
12
|
+
instance.unmount();
|
|
13
|
+
} }));
|
|
14
|
+
process.on("exit", disableBracketedPaste);
|
|
15
|
+
process.on("uncaughtException", (error) => {
|
|
16
|
+
console.error("Uncaught exception:", error);
|
|
17
|
+
disableBracketedPaste();
|
|
18
|
+
instance.unmount();
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
process.on("unhandledRejection", (reason) => {
|
|
22
|
+
console.error("Unhandled rejection:", reason);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
export function useRouter(initial = "board") {
|
|
3
|
+
const [stack, setStack] = useState([initial]);
|
|
4
|
+
const view = stack[stack.length - 1] ?? initial;
|
|
5
|
+
const canGoBack = stack.length > 1;
|
|
6
|
+
const push = useCallback((next) => {
|
|
7
|
+
setStack((s) => [...s, next]);
|
|
8
|
+
}, []);
|
|
9
|
+
const replace = useCallback((next) => {
|
|
10
|
+
setStack((s) => [...s.slice(0, -1), next]);
|
|
11
|
+
}, []);
|
|
12
|
+
const pop = useCallback(() => {
|
|
13
|
+
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
14
|
+
}, []);
|
|
15
|
+
return { view, stack, push, replace, pop, canGoBack };
|
|
16
|
+
}
|