loop-task 1.4.4 → 1.4.7
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 +4 -0
- package/dist/board/App.js +20 -7
- package/dist/board/components/CreateForm.js +10 -3
- package/dist/board/components/HelpModal.js +6 -6
- package/dist/board/components/LogModal.js +27 -3
- package/dist/board/components/Navigator.js +3 -3
- package/dist/board/components/ProjectsPage.js +13 -2
- package/dist/board/components/SearchBox.js +5 -1
- package/dist/board/components/TaskForm.js +10 -3
- package/dist/board/hooks/useBoardKeybindings.js +84 -4
- package/dist/board/hooks/useInputShortcuts.js +61 -0
- package/dist/board/hooks/useTaskKeybindings.js +22 -5
- package/dist/board/toast.js +1 -1
- package/dist/cli.js +47 -0
- package/dist/core/loop-controller.js +18 -2
- package/dist/core/scheduling.js +12 -0
- package/dist/daemon/manager.js +12 -1
- package/dist/daemon/server.js +15 -7
- package/dist/daemon/spawner.js +1 -1
- package/dist/entry.js +0 -0
- package/dist/i18n/en.json +1 -0
- package/dist/loop-config.js +1 -0
- package/dist/shared/clipboard.js +22 -0
- package/package.json +73 -74
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ loop-task # open the board (requires Bun)
|
|
|
50
50
|
loop-task start # start the daemon, restore persisted loops
|
|
51
51
|
loop-task new 30m -- npm test # create a background loop
|
|
52
52
|
loop-task run --now 10s -- echo hi # run a loop in the foreground
|
|
53
|
+
loop-task stop <id> # stop a frozen loop and kill its child process
|
|
54
|
+
loop-task restart # kill daemon + all loops, restart fresh
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
Or run it directly:
|
|
@@ -137,6 +139,8 @@ Colors can be a name (`white`, `cyan`, `green`, `yellow`, `orange`, `pink`) or a
|
|
|
137
139
|
| `loop-task new <interval> -- <command>` | Create a background loop (creates an inline task) |
|
|
138
140
|
| `loop-task new <interval> --project <name> -- <command>` | Create a loop assigned to a project |
|
|
139
141
|
| `loop-task run <interval> -- <command>` | Run a loop in the foreground |
|
|
142
|
+
| `loop-task stop <id>` | Stop a loop and interrupt its running child process |
|
|
143
|
+
| `loop-task restart` | Kill the daemon and all running loops, then restart fresh |
|
|
140
144
|
| `loop-task project list` | List all projects |
|
|
141
145
|
| `loop-task project new <name> [--color <color>]` | Create a project |
|
|
142
146
|
| `loop-task project rename <id\|name> <new-name>` | Rename a project |
|
package/dist/board/App.js
CHANGED
|
@@ -26,7 +26,7 @@ import { ProjectsPage } from "./components/ProjectsPage.js";
|
|
|
26
26
|
import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
|
|
27
27
|
import { useBreakpoint } from "./hooks/useBreakpoint.js";
|
|
28
28
|
import { useRouter } from "./router.js";
|
|
29
|
-
|
|
29
|
+
import { POLL_MS } from "../config/constants.js";
|
|
30
30
|
const VIEW_TO_MODE = {
|
|
31
31
|
create: "create",
|
|
32
32
|
"task-create": "task",
|
|
@@ -117,14 +117,20 @@ export function App(props) {
|
|
|
117
117
|
}
|
|
118
118
|
catch { /* ignore */ }
|
|
119
119
|
}
|
|
120
|
-
useState(() => { void refreshTasks(); });
|
|
121
120
|
async function refreshProjects() {
|
|
122
121
|
try {
|
|
123
122
|
setProjects(await listProjects());
|
|
124
123
|
}
|
|
125
124
|
catch { /* ignore */ }
|
|
126
125
|
}
|
|
127
|
-
|
|
126
|
+
useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const timer = setInterval(() => {
|
|
129
|
+
void refreshTasks();
|
|
130
|
+
void refreshProjects();
|
|
131
|
+
}, POLL_MS);
|
|
132
|
+
return () => clearInterval(timer);
|
|
133
|
+
}, []);
|
|
128
134
|
useEffect(() => {
|
|
129
135
|
try {
|
|
130
136
|
localStorage.setItem("loop-current-project", currentProjectId);
|
|
@@ -135,7 +141,7 @@ export function App(props) {
|
|
|
135
141
|
return async () => {
|
|
136
142
|
try {
|
|
137
143
|
await action();
|
|
138
|
-
|
|
144
|
+
void refresh();
|
|
139
145
|
pushToast("success", label);
|
|
140
146
|
}
|
|
141
147
|
catch (error) {
|
|
@@ -266,7 +272,7 @@ export function App(props) {
|
|
|
266
272
|
setPendingTaskSelection(null);
|
|
267
273
|
pop();
|
|
268
274
|
pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
|
|
269
|
-
|
|
275
|
+
void refresh();
|
|
270
276
|
};
|
|
271
277
|
const onTaskDone = (updated, id) => {
|
|
272
278
|
setEditTask(null);
|
|
@@ -309,8 +315,10 @@ export function App(props) {
|
|
|
309
315
|
onOpenRunLog: handleOpenRunLog,
|
|
310
316
|
refreshTasks,
|
|
311
317
|
onViewTasks: () => { void refreshTasks(); push("task-list"); },
|
|
312
|
-
onViewProjects: () => push("projects"),
|
|
318
|
+
onViewProjects: () => { void refreshProjects(); push("projects"); },
|
|
319
|
+
onViewLoops: () => replace("board"),
|
|
313
320
|
onAddLoop: () => { setEditTarget(null); push("create"); },
|
|
321
|
+
onAddTask: () => { setEditTask(null); push("task-create"); },
|
|
314
322
|
onSelectProject: () => setProjectsModalOpen(true),
|
|
315
323
|
});
|
|
316
324
|
useTaskKeybindings({
|
|
@@ -330,6 +338,9 @@ export function App(props) {
|
|
|
330
338
|
onTaskAction: handleTaskAction,
|
|
331
339
|
onCancel: cancelTaskList,
|
|
332
340
|
onCreateTask: handleCreateTask,
|
|
341
|
+
onEnterHeader: (direction) => {
|
|
342
|
+
setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
|
|
343
|
+
},
|
|
333
344
|
selectable: stack.includes("create") || stack.includes("task-edit"),
|
|
334
345
|
});
|
|
335
346
|
const counts = {
|
|
@@ -345,7 +356,9 @@ export function App(props) {
|
|
|
345
356
|
}
|
|
346
357
|
else {
|
|
347
358
|
push("projects");
|
|
348
|
-
} } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }
|
|
359
|
+
} } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: (direction) => {
|
|
360
|
+
setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
|
|
361
|
+
} })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
|
|
349
362
|
setEditTarget(loop);
|
|
350
363
|
push("create");
|
|
351
364
|
} } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
|
|
@@ -7,6 +7,7 @@ import { t } from "../../i18n/index.js";
|
|
|
7
7
|
import { commandLine } from "../format.js";
|
|
8
8
|
import { createLoop, updateLoop, listTasks } from "../daemon.js";
|
|
9
9
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
10
|
+
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
10
11
|
import { HOVER_BG } from "../../config/constants.js";
|
|
11
12
|
export const TASK_MODE_INLINE = "inline";
|
|
12
13
|
export const TASK_MODE_EXISTING = "existing";
|
|
@@ -47,6 +48,12 @@ export function CreateView(props) {
|
|
|
47
48
|
const [error, setError] = useState("");
|
|
48
49
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
49
50
|
const [selectedTaskName, setSelectedTaskName] = useState(props.selectedTaskName ?? null);
|
|
51
|
+
const inputRef = useRef(null);
|
|
52
|
+
useInputShortcuts(() => {
|
|
53
|
+
if (focusIndex >= saveIndex)
|
|
54
|
+
return null;
|
|
55
|
+
return inputRef.current;
|
|
56
|
+
});
|
|
50
57
|
useState(() => {
|
|
51
58
|
if (values.taskId && !selectedTaskName) {
|
|
52
59
|
void listTasks().then((tasks) => {
|
|
@@ -271,7 +278,7 @@ export function CreateView(props) {
|
|
|
271
278
|
return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { flexDirection: "column", marginBottom: 1 }, children: [_jsx("text", { fg: "#9ca3af", children: t("board.exampleHeading") }), _jsx("text", { fg: "#d1d5db", children: t("board.exampleFull") })] }), _jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(filteredFields.length / 2) }, (_, row) => {
|
|
272
279
|
const leftField = filteredFields[row * 2];
|
|
273
280
|
const rightField = row * 2 + 1 < filteredFields.length ? filteredFields[row * 2 + 1] : null;
|
|
274
|
-
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(FormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(FormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
281
|
+
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(FormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(FormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
275
282
|
}) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
|
|
276
283
|
}
|
|
277
284
|
function HoverButton(props) {
|
|
@@ -281,7 +288,7 @@ function HoverButton(props) {
|
|
|
281
288
|
return (_jsx("box", { border: true, onMouseDown: props.onMouseDown, borderColor: borderColor, style: { width: props.width, justifyContent: "center", alignItems: "center", marginRight: props.marginRight, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: props.label }) }) }));
|
|
282
289
|
}
|
|
283
290
|
function FormRow(props) {
|
|
284
|
-
const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, style } = props;
|
|
291
|
+
const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, inputRef, style } = props;
|
|
285
292
|
const { isHovered, hoverProps } = useHoverState();
|
|
286
293
|
const isFocused = focusIndex === index;
|
|
287
294
|
const isToggleField = field === "taskMode" || field === "runNow";
|
|
@@ -297,7 +304,7 @@ function FormRow(props) {
|
|
|
297
304
|
: t("board.chooseTask") }) })) : isProjectField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => setFocusIndex(index), children: projectOptions.map((opt) => {
|
|
298
305
|
const isActive = values.project === opt.value;
|
|
299
306
|
return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, project: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, children: _jsx("text", { fg: isActive ? "#ffffff" : "#9ca3af", children: `● ${opt.name}` }) }, opt.value));
|
|
300
|
-
}) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
307
|
+
}) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
301
308
|
if (index < fields.length - 1) {
|
|
302
309
|
setFocusIndex(index + 1);
|
|
303
310
|
}
|
|
@@ -5,8 +5,8 @@ export function HelpModal(props) {
|
|
|
5
5
|
const { view } = props;
|
|
6
6
|
const { width } = useTerminalDimensions();
|
|
7
7
|
const loopRows = [
|
|
8
|
-
["tab
|
|
9
|
-
["
|
|
8
|
+
["tab", "switch panels"],
|
|
9
|
+
["up/down", "navigate list items"],
|
|
10
10
|
["enter", t("board.helpEdit")],
|
|
11
11
|
["e", "edit loop"],
|
|
12
12
|
["d/del", "delete loop"],
|
|
@@ -24,8 +24,8 @@ export function HelpModal(props) {
|
|
|
24
24
|
["esc", t("board.helpQuit")],
|
|
25
25
|
];
|
|
26
26
|
const taskRows = [
|
|
27
|
-
["tab
|
|
28
|
-
["
|
|
27
|
+
["tab", "switch panels"],
|
|
28
|
+
["up/down", "navigate tasks"],
|
|
29
29
|
["enter", "focus actions panel"],
|
|
30
30
|
["e", "edit task"],
|
|
31
31
|
["d/del", "delete task"],
|
|
@@ -36,8 +36,8 @@ export function HelpModal(props) {
|
|
|
36
36
|
["esc", t("board.helpQuit")],
|
|
37
37
|
];
|
|
38
38
|
const projectRows = [
|
|
39
|
-
["tab
|
|
40
|
-
["
|
|
39
|
+
["tab", "switch panel (list/actions)"],
|
|
40
|
+
["up/down", "navigate projects"],
|
|
41
41
|
["enter", "focus actions"],
|
|
42
42
|
["n", "new project"],
|
|
43
43
|
["e", "edit project"],
|
|
@@ -6,12 +6,31 @@ import { formatRunDuration, formatRunTime } from "../format.js";
|
|
|
6
6
|
import { streamRunLog } from "../daemon.js";
|
|
7
7
|
import { LOG_LINES_MAX } from "../../config/constants.js";
|
|
8
8
|
import { copyToClipboard } from "../../shared/clipboard.js";
|
|
9
|
+
function colorizeLine(line) {
|
|
10
|
+
if (/^\[Run #/.test(line)) {
|
|
11
|
+
return _jsx("text", { fg: "#38bdf8", children: _jsx("strong", { children: line }) });
|
|
12
|
+
}
|
|
13
|
+
if (/^--- Chain:/.test(line)) {
|
|
14
|
+
return _jsx("text", { fg: "#a78bfa", children: _jsx("strong", { children: line }) });
|
|
15
|
+
}
|
|
16
|
+
if (/^\[exit 0/.test(line)) {
|
|
17
|
+
return _jsx("text", { fg: "#4ade80", children: line });
|
|
18
|
+
}
|
|
19
|
+
if (/^\[exit /.test(line)) {
|
|
20
|
+
return _jsx("text", { fg: "#f87171", children: line });
|
|
21
|
+
}
|
|
22
|
+
if (/^\[error\]/.test(line)) {
|
|
23
|
+
return _jsx("text", { fg: "#f87171", children: line });
|
|
24
|
+
}
|
|
25
|
+
return _jsx("text", { fg: "#e5e7eb", children: line });
|
|
26
|
+
}
|
|
9
27
|
export function LogModal(props) {
|
|
10
28
|
const { loopId, run, logLines: staticLines, loading } = props;
|
|
11
29
|
const { width, height } = useTerminalDimensions();
|
|
12
30
|
const isRunning = run.status === "running";
|
|
13
31
|
const [streamLines, setStreamLines] = useState([]);
|
|
14
32
|
const socketRef = useRef(null);
|
|
33
|
+
const scrollRef = useRef(null);
|
|
15
34
|
useEffect(() => {
|
|
16
35
|
if (!isRunning || !loopId)
|
|
17
36
|
return;
|
|
@@ -28,13 +47,18 @@ export function LogModal(props) {
|
|
|
28
47
|
}
|
|
29
48
|
};
|
|
30
49
|
}, [isRunning, loopId, run.runNumber]);
|
|
50
|
+
const logLines = isRunning ? streamLines : staticLines;
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (logLines.length > 0) {
|
|
53
|
+
scrollRef.current?.scrollChildIntoView(`log-line-${logLines.length - 1}`);
|
|
54
|
+
}
|
|
55
|
+
}, [logLines]);
|
|
31
56
|
useKeyboard((key) => {
|
|
32
57
|
if (key.ctrl && key.name === "c") {
|
|
33
|
-
const all =
|
|
58
|
+
const all = logLines.join("\n");
|
|
34
59
|
copyToClipboard(all);
|
|
35
60
|
}
|
|
36
61
|
});
|
|
37
|
-
const logLines = isRunning ? streamLines : staticLines;
|
|
38
62
|
const success = isRunning ? true : run.exitCode === 0;
|
|
39
63
|
const icon = isRunning ? "⟳" : success ? "✓" : "✗";
|
|
40
64
|
const iconColor = isRunning ? "#facc15" : success ? "#4ade80" : "#f87171";
|
|
@@ -54,5 +78,5 @@ export function LogModal(props) {
|
|
|
54
78
|
width: Math.min(width - 4, 100),
|
|
55
79
|
height: Math.min(height - 4, 30),
|
|
56
80
|
backgroundColor: "#111827",
|
|
57
|
-
}, children: [_jsx("scrollbox", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => _jsx("
|
|
81
|
+
}, children: [_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => (_jsx("box", { id: `log-line-${i}`, children: colorizeLine(line) }, i)))) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", backgroundColor: "#111827" }, children: [_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), " ", isRunning ? (_jsx("span", { fg: "#facc15", children: t("board.logModalRunning") })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#9ca3af", children: formatRunDuration(run.duration) }), " ", _jsxs("span", { fg: success ? "#4ade80" : "#f87171", children: ["exit ", run.exitCode] })] }))] }), _jsx("text", { fg: "#6b7280", children: t("board.logModalEscClose") })] })] }) }));
|
|
58
82
|
}
|
|
@@ -26,9 +26,9 @@ export function Navigator(props) {
|
|
|
26
26
|
const bulletW = 2;
|
|
27
27
|
const nonVar = 2 + 1 + statusW + 1 + 1 + 1 + exitW + 1 + 1 + runsW + 1 + skpW;
|
|
28
28
|
const avail = Math.floor(panelWidth) - nonVar - bulletW;
|
|
29
|
-
const descW = Math.min(
|
|
30
|
-
const
|
|
31
|
-
const
|
|
29
|
+
const descW = Math.min(32, Math.max(10, Math.round(avail * 0.38)));
|
|
30
|
+
const timingW = Math.min(16, Math.max(6, Math.round(avail * 0.25)));
|
|
31
|
+
const sinceW = Math.max(8, avail - descW - timingW);
|
|
32
32
|
const header = " " +
|
|
33
33
|
fit("", bulletW) +
|
|
34
34
|
fit(t("board.headerDescription"), descW) +
|
|
@@ -15,7 +15,7 @@ function ProjectActionButton(props) {
|
|
|
15
15
|
return (_jsx("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) }) }));
|
|
16
16
|
}
|
|
17
17
|
export function ProjectsPage(props) {
|
|
18
|
-
const { projects, loops, onClose, onRefresh, onOpenCreate } = props;
|
|
18
|
+
const { projects, loops, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
|
|
19
19
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
20
20
|
const [subModal, setSubModal] = useState("none");
|
|
21
21
|
const [focusedPanel, setFocusedPanel] = useState("list");
|
|
@@ -52,7 +52,18 @@ export function ProjectsPage(props) {
|
|
|
52
52
|
key.preventDefault();
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
|
-
if (key.name === "
|
|
55
|
+
if (key.name === "tab") {
|
|
56
|
+
const direction = key.shift ? "left" : "right";
|
|
57
|
+
if (focusedPanel === "list" && direction === "left") {
|
|
58
|
+
onEnterHeader?.("left");
|
|
59
|
+
key.preventDefault();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (focusedPanel === "actions" && direction === "right") {
|
|
63
|
+
onEnterHeader?.("right");
|
|
64
|
+
key.preventDefault();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
56
67
|
setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
|
|
57
68
|
key.preventDefault();
|
|
58
69
|
return;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useRef } from "react";
|
|
2
3
|
import { t } from "../../i18n/index.js";
|
|
4
|
+
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
3
5
|
export function SearchBox(props) {
|
|
4
6
|
const { query, searchActive, focused, onQueryChange, onActivate, onDismiss } = props;
|
|
5
|
-
|
|
7
|
+
const inputRef = useRef(null);
|
|
8
|
+
useInputShortcuts(() => searchActive ? inputRef.current : null);
|
|
9
|
+
return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { ref: inputRef, focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
|
|
6
10
|
}
|
|
@@ -5,6 +5,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
6
|
import { createTask, updateTask, listTasks } from "../daemon.js";
|
|
7
7
|
import { useHoverState } from "../hooks/useHoverState.js";
|
|
8
|
+
import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
|
|
8
9
|
import { HOVER_BG } from "../../config/constants.js";
|
|
9
10
|
const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
|
|
10
11
|
function taskInitialValues(task) {
|
|
@@ -25,8 +26,14 @@ export function TaskForm(props) {
|
|
|
25
26
|
const [error, setError] = useState("");
|
|
26
27
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
28
|
const [allTasks, setAllTasks] = useState([]);
|
|
29
|
+
const inputRef = useRef(null);
|
|
28
30
|
const { width: termWidth } = useTerminalDimensions();
|
|
29
31
|
const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
|
|
32
|
+
useInputShortcuts(() => {
|
|
33
|
+
if (focusIndex >= saveIndex)
|
|
34
|
+
return null;
|
|
35
|
+
return inputRef.current;
|
|
36
|
+
});
|
|
30
37
|
const saveIndex = taskFields.length;
|
|
31
38
|
const cancelIndex = taskFields.length + 1;
|
|
32
39
|
function updateValues(next) {
|
|
@@ -140,16 +147,16 @@ export function TaskForm(props) {
|
|
|
140
147
|
return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(taskFields.length / 2) }, (_, row) => {
|
|
141
148
|
const leftField = taskFields[row * 2];
|
|
142
149
|
const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
|
|
143
|
-
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
150
|
+
return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
|
|
144
151
|
}) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
|
|
145
152
|
}
|
|
146
153
|
function TaskFormRow(props) {
|
|
147
|
-
const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, style } = props;
|
|
154
|
+
const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
|
|
148
155
|
const isFocused = focusIndex === index;
|
|
149
156
|
const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
|
|
150
157
|
const selectOpts = isSelect ? chainOptions : [];
|
|
151
158
|
const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
|
|
152
|
-
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
159
|
+
return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
|
|
153
160
|
if (index < taskFields.length - 1) {
|
|
154
161
|
setFocusIndex(index + 1);
|
|
155
162
|
}
|
|
@@ -195,7 +195,7 @@ const BOARD_SHORTCUTS = {
|
|
|
195
195
|
r: (p) => p.onSelectProject?.(),
|
|
196
196
|
};
|
|
197
197
|
export function useBoardKeybindings(params) {
|
|
198
|
-
const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onAddLoop, onSelectProject, } = params;
|
|
198
|
+
const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onViewLoops, onAddLoop, onAddTask, onSelectProject, } = params;
|
|
199
199
|
useKeyboard((key) => {
|
|
200
200
|
const name = key.name;
|
|
201
201
|
if (confirm) {
|
|
@@ -230,6 +230,19 @@ export function useBoardKeybindings(params) {
|
|
|
230
230
|
key.preventDefault();
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
|
+
if (name === "up" || name === "k" || name === "down" || name === "j") {
|
|
234
|
+
const handler = panelHandlers["loops"];
|
|
235
|
+
if (handler) {
|
|
236
|
+
handler(name, {
|
|
237
|
+
setSearchActive, setFilters, setSort, setEditTarget, push,
|
|
238
|
+
setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
|
|
239
|
+
selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
|
|
240
|
+
refreshTasks, onViewTasks, onViewProjects, onAddLoop,
|
|
241
|
+
});
|
|
242
|
+
key.preventDefault();
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
233
246
|
return;
|
|
234
247
|
}
|
|
235
248
|
if (view !== "board" && name === "escape") {
|
|
@@ -237,11 +250,78 @@ export function useBoardKeybindings(params) {
|
|
|
237
250
|
key.preventDefault();
|
|
238
251
|
return;
|
|
239
252
|
}
|
|
253
|
+
if (name === "tab" && view !== "board") {
|
|
254
|
+
const HEADER_PANELS = ["header-tasks", "header-projects", "header-new"];
|
|
255
|
+
const isHeader = HEADER_PANELS.includes(focusedPanel);
|
|
256
|
+
const direction = key.shift ? "left" : "right";
|
|
257
|
+
if (isHeader) {
|
|
258
|
+
const idx = HEADER_PANELS.indexOf(focusedPanel);
|
|
259
|
+
const nextIdx = direction === "right"
|
|
260
|
+
? (idx + 1) % HEADER_PANELS.length
|
|
261
|
+
: (idx - 1 + HEADER_PANELS.length) % HEADER_PANELS.length;
|
|
262
|
+
setFocusedPanel(HEADER_PANELS[nextIdx]);
|
|
263
|
+
key.preventDefault();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (view !== "board" && (name === "return" || name === "enter")) {
|
|
268
|
+
if (view === "task-list") {
|
|
269
|
+
if (focusedPanel === "header-tasks") {
|
|
270
|
+
onViewProjects?.();
|
|
271
|
+
key.preventDefault();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (focusedPanel === "header-projects") {
|
|
275
|
+
onViewLoops?.();
|
|
276
|
+
key.preventDefault();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (focusedPanel === "header-new") {
|
|
280
|
+
onAddTask?.();
|
|
281
|
+
key.preventDefault();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (view === "projects") {
|
|
286
|
+
if (focusedPanel === "header-tasks") {
|
|
287
|
+
onViewLoops?.();
|
|
288
|
+
key.preventDefault();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (focusedPanel === "header-projects") {
|
|
292
|
+
onViewTasks?.();
|
|
293
|
+
key.preventDefault();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (focusedPanel === "header-new") {
|
|
297
|
+
onViewProjects?.();
|
|
298
|
+
key.preventDefault();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (view === "create" || view === "task-create" || view === "task-edit") {
|
|
303
|
+
if (focusedPanel === "header-tasks") {
|
|
304
|
+
onViewProjects?.();
|
|
305
|
+
key.preventDefault();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (focusedPanel === "header-projects") {
|
|
309
|
+
onViewTasks?.();
|
|
310
|
+
key.preventDefault();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (focusedPanel === "header-new") {
|
|
314
|
+
onAddLoop?.();
|
|
315
|
+
key.preventDefault();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
240
320
|
if (view !== "board")
|
|
241
321
|
return;
|
|
242
|
-
if (name === "
|
|
243
|
-
const direction =
|
|
244
|
-
if (focusedPanel === "actions"
|
|
322
|
+
if (name === "tab") {
|
|
323
|
+
const direction = key.shift ? "left" : "right";
|
|
324
|
+
if (focusedPanel === "actions") {
|
|
245
325
|
const actionCount = selected ? getActionCount(selected.status) : 0;
|
|
246
326
|
if (direction === "left" && selectedAction === 0) {
|
|
247
327
|
setFocusedPanel((p) => nextPanel(p, "left"));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import { copyToClipboard, readFromClipboard } from "../../shared/clipboard.js";
|
|
3
|
+
export function useInputShortcuts(getActiveInput) {
|
|
4
|
+
useKeyboard((key) => {
|
|
5
|
+
if (!key.ctrl)
|
|
6
|
+
return;
|
|
7
|
+
const input = getActiveInput();
|
|
8
|
+
if (!input)
|
|
9
|
+
return;
|
|
10
|
+
const name = key.name;
|
|
11
|
+
if (name === "c") {
|
|
12
|
+
try {
|
|
13
|
+
const text = input.getSelectedText();
|
|
14
|
+
if (text) {
|
|
15
|
+
copyToClipboard(text);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
console.error(`[loop-task] copy failed: ${e}`);
|
|
20
|
+
}
|
|
21
|
+
key.preventDefault();
|
|
22
|
+
key.stopPropagation();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (name === "x") {
|
|
26
|
+
try {
|
|
27
|
+
const text = input.getSelectedText();
|
|
28
|
+
if (text) {
|
|
29
|
+
copyToClipboard(text);
|
|
30
|
+
input.deleteSelection();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
console.error(`[loop-task] cut failed: ${e}`);
|
|
35
|
+
}
|
|
36
|
+
key.preventDefault();
|
|
37
|
+
key.stopPropagation();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (name === "v") {
|
|
41
|
+
try {
|
|
42
|
+
const text = readFromClipboard();
|
|
43
|
+
if (text) {
|
|
44
|
+
input.insertText(text);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.error(`[loop-task] paste failed: ${e}`);
|
|
49
|
+
}
|
|
50
|
+
key.preventDefault();
|
|
51
|
+
key.stopPropagation();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (name === "a") {
|
|
55
|
+
input.selectAll();
|
|
56
|
+
key.preventDefault();
|
|
57
|
+
key.stopPropagation();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useKeyboard } from "@opentui/react";
|
|
2
2
|
import { nextTaskPanel } from "../components/TaskBrowser.js";
|
|
3
3
|
export function useTaskKeybindings(params) {
|
|
4
|
-
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, selectable = true, } = params;
|
|
4
|
+
const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, selectable = true, } = params;
|
|
5
5
|
const taskActions = selectable
|
|
6
6
|
? ["select", "edit", "delete"]
|
|
7
7
|
: ["edit", "delete"];
|
|
@@ -19,6 +19,16 @@ export function useTaskKeybindings(params) {
|
|
|
19
19
|
key.preventDefault();
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
|
+
if (name === "up" || name === "k") {
|
|
23
|
+
setTaskSelectedIndex((i) => Math.max(0, i - 1));
|
|
24
|
+
key.preventDefault();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (name === "down" || name === "j") {
|
|
28
|
+
setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
|
|
29
|
+
key.preventDefault();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
22
32
|
return;
|
|
23
33
|
}
|
|
24
34
|
if (name === "escape") {
|
|
@@ -36,14 +46,16 @@ export function useTaskKeybindings(params) {
|
|
|
36
46
|
key.preventDefault();
|
|
37
47
|
return;
|
|
38
48
|
}
|
|
39
|
-
if (name === "tab"
|
|
40
|
-
const direction =
|
|
41
|
-
if (taskFocusedPanel === "actions"
|
|
49
|
+
if (name === "tab") {
|
|
50
|
+
const direction = key.shift ? "left" : "right";
|
|
51
|
+
if (taskFocusedPanel === "actions") {
|
|
42
52
|
if (direction === "left" && taskSelectedAction === 0) {
|
|
43
53
|
setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
|
|
44
54
|
}
|
|
45
55
|
else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
|
|
46
|
-
|
|
56
|
+
onEnterHeader("right");
|
|
57
|
+
key.preventDefault();
|
|
58
|
+
return;
|
|
47
59
|
}
|
|
48
60
|
else {
|
|
49
61
|
setTaskSelectedAction((i) => direction === "right"
|
|
@@ -51,6 +63,11 @@ export function useTaskKeybindings(params) {
|
|
|
51
63
|
: Math.max(0, i - 1));
|
|
52
64
|
}
|
|
53
65
|
}
|
|
66
|
+
else if (taskFocusedPanel === "search" && direction === "left") {
|
|
67
|
+
onEnterHeader("left");
|
|
68
|
+
key.preventDefault();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
54
71
|
else {
|
|
55
72
|
setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
|
|
56
73
|
}
|
package/dist/board/toast.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { Logger } from "./logger.js";
|
|
5
5
|
import { runLoop } from "./core/foreground-loop.js";
|
|
6
6
|
import { buildLoopOptions } from "./loop-config.js";
|
|
7
|
+
import { parseDuration } from "./duration.js";
|
|
7
8
|
import { startLoop } from "./client/commands.js";
|
|
8
9
|
import { listProjectsCli, createProjectCli, renameProjectCli, setProjectColorCli, deleteProjectCli, resolveProjectId, } from "./client/commands.js";
|
|
9
10
|
import { t } from "./i18n/index.js";
|
|
@@ -22,6 +23,49 @@ program
|
|
|
22
23
|
ensureDaemon();
|
|
23
24
|
console.log(t("cli.daemonStarted"));
|
|
24
25
|
});
|
|
26
|
+
program
|
|
27
|
+
.command("stop")
|
|
28
|
+
.description("Stop a running loop and interrupt its current execution")
|
|
29
|
+
.argument("<id>", "Loop ID")
|
|
30
|
+
.action(async (id) => {
|
|
31
|
+
try {
|
|
32
|
+
const { sendRequest } = await import("./client/ipc.js");
|
|
33
|
+
const res = await sendRequest({ type: "stop-loop", payload: { id } });
|
|
34
|
+
if (res.type === "ok" && res.data) {
|
|
35
|
+
console.log(`Stopped loop ${id}`);
|
|
36
|
+
}
|
|
37
|
+
else if (res.type === "error") {
|
|
38
|
+
console.error(res.message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(`Loop ${id} not found`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
console.error(t("cli.error", { message }));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
program
|
|
53
|
+
.command("restart")
|
|
54
|
+
.description("Kill the daemon and all running loops, then restart fresh")
|
|
55
|
+
.action(async () => {
|
|
56
|
+
const { stopDaemon, ensureDaemon } = await import("./daemon/spawner.js");
|
|
57
|
+
const { readDaemonPid, removeDaemonPid, removeDaemonSignature } = await import("./daemon/state.js");
|
|
58
|
+
const pid = readDaemonPid();
|
|
59
|
+
if (pid !== null) {
|
|
60
|
+
console.log("Stopping daemon...");
|
|
61
|
+
stopDaemon(pid);
|
|
62
|
+
removeDaemonPid();
|
|
63
|
+
removeDaemonSignature();
|
|
64
|
+
}
|
|
65
|
+
console.log("Starting fresh daemon...");
|
|
66
|
+
ensureDaemon();
|
|
67
|
+
console.log("Daemon restarted.");
|
|
68
|
+
});
|
|
25
69
|
program
|
|
26
70
|
.command("new")
|
|
27
71
|
.description(t("cli.newDescription"))
|
|
@@ -32,17 +76,20 @@ program
|
|
|
32
76
|
.option("--verbose", t("cli.optVerbose"), false)
|
|
33
77
|
.option("--cwd <dir>", t("cli.optCwd"))
|
|
34
78
|
.option("--project <name>", t("cli.optProject"))
|
|
79
|
+
.option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
|
|
35
80
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
36
81
|
try {
|
|
37
82
|
const projectId = opts.project
|
|
38
83
|
? await resolveProjectId(opts.project)
|
|
39
84
|
: undefined;
|
|
85
|
+
const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
|
|
40
86
|
const built = buildLoopOptions(intervalStr, {
|
|
41
87
|
...opts,
|
|
42
88
|
command: cmdArgs[0],
|
|
43
89
|
commandArgs: cmdArgs.slice(1),
|
|
44
90
|
cwd: opts.cwd ?? process.cwd(),
|
|
45
91
|
projectId,
|
|
92
|
+
offset: offsetMs,
|
|
46
93
|
});
|
|
47
94
|
await startLoop(built.options, built.intervalHuman);
|
|
48
95
|
}
|
|
@@ -5,6 +5,8 @@ import { sleep } from "../shared/sleep.js";
|
|
|
5
5
|
import { SLEEP_CHUNK_MS } from "../config/constants.js";
|
|
6
6
|
import { executeCommand } from "./command-runner.js";
|
|
7
7
|
import { rotateLogIfNeeded } from "./log-rotator.js";
|
|
8
|
+
import { computePhase, alignToPhase } from "./scheduling.js";
|
|
9
|
+
import { t } from "../i18n/index.js";
|
|
8
10
|
export class LoopController extends EventEmitter {
|
|
9
11
|
abortController;
|
|
10
12
|
runAbortController = null;
|
|
@@ -115,7 +117,11 @@ export class LoopController extends EventEmitter {
|
|
|
115
117
|
this._resetSchedule = true;
|
|
116
118
|
this._paused = false;
|
|
117
119
|
this._status = "waiting";
|
|
118
|
-
|
|
120
|
+
const phase = this.options.offset !== null
|
|
121
|
+
? this.options.offset
|
|
122
|
+
: computePhase(this.id, this.options.interval);
|
|
123
|
+
const delay = alignToPhase(Date.now(), this.options.interval, phase);
|
|
124
|
+
this.nextRunAt = new Date(Date.now() + delay).toISOString();
|
|
119
125
|
if (this.resumeResolve) {
|
|
120
126
|
this.resumeResolve();
|
|
121
127
|
this.resumeResolve = null;
|
|
@@ -273,7 +279,14 @@ export class LoopController extends EventEmitter {
|
|
|
273
279
|
}
|
|
274
280
|
}
|
|
275
281
|
else if (isFirstRun && !this.options.immediate) {
|
|
276
|
-
const
|
|
282
|
+
const phase = this.options.offset !== null
|
|
283
|
+
? this.options.offset
|
|
284
|
+
: computePhase(this.id, this.options.interval);
|
|
285
|
+
const delay = alignToPhase(Date.now(), this.options.interval, phase);
|
|
286
|
+
if (delay > 0) {
|
|
287
|
+
this.nextRunAt = new Date(Date.now() + delay).toISOString();
|
|
288
|
+
}
|
|
289
|
+
const completed = await this.waitForDelay(delay, signal);
|
|
277
290
|
if (!completed) {
|
|
278
291
|
break;
|
|
279
292
|
}
|
|
@@ -344,6 +357,9 @@ export class LoopController extends EventEmitter {
|
|
|
344
357
|
const chainTask = this.taskResolver(currentTargetId);
|
|
345
358
|
if (!chainTask)
|
|
346
359
|
break;
|
|
360
|
+
if (this.logStream) {
|
|
361
|
+
this.logStream.write(t("loop.chainHeader", { name: chainTask.name }));
|
|
362
|
+
}
|
|
347
363
|
const chainStartedAt = new Date().toISOString();
|
|
348
364
|
const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
|
|
349
365
|
this.runHistory.push({
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function computePhase(loopId, intervalMs) {
|
|
2
|
+
let hash = 0;
|
|
3
|
+
for (let i = 0; i < loopId.length; i++) {
|
|
4
|
+
hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
|
|
5
|
+
}
|
|
6
|
+
return Math.abs(hash) % intervalMs;
|
|
7
|
+
}
|
|
8
|
+
export function alignToPhase(now, intervalMs, phaseMs) {
|
|
9
|
+
const elapsed = now % intervalMs;
|
|
10
|
+
const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
|
|
11
|
+
return delay;
|
|
12
|
+
}
|
package/dist/daemon/manager.js
CHANGED
|
@@ -44,6 +44,7 @@ export class LoopManager {
|
|
|
44
44
|
verbose: meta.verbose,
|
|
45
45
|
description: meta.description ?? "",
|
|
46
46
|
projectId: meta.projectId ?? "default",
|
|
47
|
+
offset: meta.offset ?? null,
|
|
47
48
|
};
|
|
48
49
|
const logPath = getLogPath(meta.id);
|
|
49
50
|
const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
|
|
@@ -161,10 +162,19 @@ export class LoopManager {
|
|
|
161
162
|
const entry = this.loops.get(id);
|
|
162
163
|
if (!entry)
|
|
163
164
|
return false;
|
|
164
|
-
entry.controller.stopLoop();
|
|
165
|
+
entry.controller.stopLoop(true);
|
|
165
166
|
this.persist(id, entry.controller, entry.options, entry.intervalHuman);
|
|
166
167
|
return true;
|
|
167
168
|
}
|
|
169
|
+
stopAllLoops() {
|
|
170
|
+
let count = 0;
|
|
171
|
+
for (const [id, entry] of this.loops) {
|
|
172
|
+
entry.controller.stopLoop(true);
|
|
173
|
+
this.persist(id, entry.controller, entry.options, entry.intervalHuman);
|
|
174
|
+
count++;
|
|
175
|
+
}
|
|
176
|
+
return count;
|
|
177
|
+
}
|
|
168
178
|
playLoop(id) {
|
|
169
179
|
const entry = this.loops.get(id);
|
|
170
180
|
if (!entry)
|
|
@@ -255,6 +265,7 @@ export class LoopManager {
|
|
|
255
265
|
remainingDelayMs: runtime.remainingDelayMs,
|
|
256
266
|
pid: process.pid,
|
|
257
267
|
projectId: options.projectId ?? "default",
|
|
268
|
+
offset: options.offset,
|
|
258
269
|
};
|
|
259
270
|
}
|
|
260
271
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -112,6 +112,11 @@ export class IpcServer {
|
|
|
112
112
|
this.respondOk(socket, this.manager.stopLoop(request.payload.id), request.payload.id);
|
|
113
113
|
break;
|
|
114
114
|
}
|
|
115
|
+
case "stop-all": {
|
|
116
|
+
const count = this.manager.stopAllLoops();
|
|
117
|
+
send(socket, { type: "ok", data: count });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
115
120
|
case "play-loop": {
|
|
116
121
|
if (this.manager.isMaxRunsBlocked(request.payload.id)) {
|
|
117
122
|
send(socket, { type: "error", message: t("errors.maxRunsReached") });
|
|
@@ -257,8 +262,10 @@ export class IpcServer {
|
|
|
257
262
|
send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
|
|
258
263
|
return;
|
|
259
264
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
265
|
+
const records = meta.runHistory
|
|
266
|
+
.filter((r) => r.runNumber === runNumber)
|
|
267
|
+
.sort((a, b) => a.logOffset - b.logOffset);
|
|
268
|
+
if (records.length === 0) {
|
|
262
269
|
send(socket, { type: "ok", data: "" });
|
|
263
270
|
return;
|
|
264
271
|
}
|
|
@@ -267,12 +274,12 @@ export class IpcServer {
|
|
|
267
274
|
send(socket, { type: "ok", data: "" });
|
|
268
275
|
return;
|
|
269
276
|
}
|
|
270
|
-
const
|
|
277
|
+
const firstOffset = records[0].logOffset;
|
|
271
278
|
const stat = fs.statSync(logPath);
|
|
272
|
-
if (stat.size >
|
|
279
|
+
if (stat.size > firstOffset) {
|
|
273
280
|
const fd = fs.openSync(logPath, "r");
|
|
274
|
-
const buf = Buffer.alloc(stat.size -
|
|
275
|
-
fs.readSync(fd, buf, 0, buf.length,
|
|
281
|
+
const buf = Buffer.alloc(stat.size - firstOffset);
|
|
282
|
+
fs.readSync(fd, buf, 0, buf.length, firstOffset);
|
|
276
283
|
fs.closeSync(fd);
|
|
277
284
|
for (const line of buf.toString().split("\n")) {
|
|
278
285
|
if (line) {
|
|
@@ -280,7 +287,8 @@ export class IpcServer {
|
|
|
280
287
|
}
|
|
281
288
|
}
|
|
282
289
|
}
|
|
283
|
-
|
|
290
|
+
const allCompleted = records.every((r) => r.status === "completed");
|
|
291
|
+
if (allCompleted) {
|
|
284
292
|
send(socket, { type: "end" });
|
|
285
293
|
return;
|
|
286
294
|
}
|
package/dist/daemon/spawner.js
CHANGED
package/dist/entry.js
CHANGED
|
File without changes
|
package/dist/i18n/en.json
CHANGED
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"cli.projectAmbiguous": "Multiple projects named \"{name}\" - use the id instead",
|
|
66
66
|
"cli.projectInvalidColor": "Invalid color \"{color}\". Valid names: {valid}, or a #rrggbb hex",
|
|
67
67
|
"loop.runHeader": "\n[Run #{runNumber} - {timestamp}]\n",
|
|
68
|
+
"loop.chainHeader": "\n--- Chain: {name} ---\n",
|
|
68
69
|
"loop.exitMarker": "[exit {code} · {duration}]\n",
|
|
69
70
|
"loop.cwdMissingLog": "[error] working directory does not exist: {cwd}\n",
|
|
70
71
|
"loop.cwdMissing": "Working directory does not exist: {cwd}",
|
package/dist/loop-config.js
CHANGED
package/dist/shared/clipboard.js
CHANGED
|
@@ -17,3 +17,25 @@ export function copyToClipboard(text) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
export function readFromClipboard() {
|
|
21
|
+
const os = platform();
|
|
22
|
+
try {
|
|
23
|
+
if (os === "win32") {
|
|
24
|
+
return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
|
|
25
|
+
}
|
|
26
|
+
else if (os === "darwin") {
|
|
27
|
+
return execFileSync("pbpaste", { encoding: "utf-8" });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,75 +1,74 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "loop-task",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"loop-task": "dist/entry.js"
|
|
8
|
-
},
|
|
9
|
-
"main": "dist/entry.js",
|
|
10
|
-
"files": [
|
|
11
|
-
"dist"
|
|
12
|
-
],
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "loop-task",
|
|
3
|
+
"version": "1.4.7",
|
|
4
|
+
"description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"loop-task": "dist/entry.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/entry.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"cli",
|
|
15
|
+
"loop",
|
|
16
|
+
"loop-engineering",
|
|
17
|
+
"repeat",
|
|
18
|
+
"interval",
|
|
19
|
+
"schedule",
|
|
20
|
+
"timer",
|
|
21
|
+
"cron",
|
|
22
|
+
"automation",
|
|
23
|
+
"automations",
|
|
24
|
+
"devops",
|
|
25
|
+
"agent",
|
|
26
|
+
"ai-agent",
|
|
27
|
+
"coding-agent",
|
|
28
|
+
"agent-automation",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"codex",
|
|
31
|
+
"opencode",
|
|
32
|
+
"background-tasks",
|
|
33
|
+
"scheduler"
|
|
34
|
+
],
|
|
35
|
+
"author": "Quique Fdez Guerra",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@opentui/core": "^0.4.1",
|
|
42
|
+
"@opentui/react": "^0.4.1",
|
|
43
|
+
"commander": "^13.1.0",
|
|
44
|
+
"execa": "^9.6.0",
|
|
45
|
+
"ms": "^2.1.3",
|
|
46
|
+
"react": "^19.2.7"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/ms": "^2.1.0",
|
|
50
|
+
"@types/node": "^22.15.0",
|
|
51
|
+
"@types/react": "^19.2.17",
|
|
52
|
+
"@vitest/coverage-v8": "^3.1.0",
|
|
53
|
+
"eslint": "^9.25.0",
|
|
54
|
+
"tsx": "^4.19.0",
|
|
55
|
+
"typescript": "^5.8.0",
|
|
56
|
+
"typescript-eslint": "^8.30.0",
|
|
57
|
+
"vitest": "^3.1.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
|
|
61
|
+
"start": "node dist/entry.js",
|
|
62
|
+
"dev": "bun src/cli.ts",
|
|
63
|
+
"dev:watch": "bun --watch src/cli.ts",
|
|
64
|
+
"board": "node dist/entry.js",
|
|
65
|
+
"test": "vitest run --exclude '**/background-cli.test.ts'",
|
|
66
|
+
"test:all": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"test:coverage": "vitest run --coverage --exclude '**/background-cli.test.ts'",
|
|
69
|
+
"lint": "eslint src/ tests/",
|
|
70
|
+
"typecheck": "tsc --noEmit",
|
|
71
|
+
"release:dry": "npm publish --dry-run",
|
|
72
|
+
"release": "npm publish"
|
|
73
|
+
}
|
|
75
74
|
}
|