loop-task 2.0.0 → 2.0.2
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 +2 -16
- package/dist/i18n/en.json +6 -6
- package/dist/tui/app.js +1 -1
- package/dist/tui/components/CommandInput.js +16 -38
- package/dist/tui/components/CreateForm.js +2 -2
- package/dist/tui/components/WizardForm.js +18 -4
- package/dist/tui/index.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,27 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
`loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground. It is the **heartbeat** primitive for [loop engineering](#loop-engineering): instead of running a task by hand every time, you schedule it once and let it run.
|
|
9
9
|
|
|
10
|
+
**[loop.ckgrafico.com](https://loop.ckgrafico.com)**
|
|
11
|
+
|
|
10
12
|
[](https://www.npmjs.com/package/loop-task)
|
|
11
13
|
[](https://www.npmjs.com/package/loop-task)
|
|
12
14
|
[](./LICENSE)
|
|
13
15
|
[](https://nodejs.org)
|
|
14
16
|
|
|
15
|
-
## What's new in v2.0.0
|
|
16
|
-
|
|
17
|
-
- **Ink 7 + React 19 TUI**: Rebuilt the entire terminal UI on [Ink 7](https://github.com/vadimdemedes/ink) (React 19 for CLI). No more Bun dependency — runs on any Node >= 20.
|
|
18
|
-
- **HTTP API + Swagger**: The daemon now exposes a REST + SSE API on `localhost:8845`. Browse it at `http://127.0.0.1:8845/api/docs` (Swagger UI) or fetch the OpenAPI spec at `/api/openapi.json`. Manage loops, tasks, projects, and logs from any HTTP client.
|
|
19
|
-
- **Hot-reloading configs**: Edit `~/.loop-cli/loops.json`, `tasks.json`, or `projects.json` in any editor and the daemon auto-reloads in real-time.
|
|
20
|
-
- **Export/Import**: `loop-task export > backup.json` and `loop-task import backup.json` for backup and sharing.
|
|
21
|
-
- **CLI API**: `loop-task status --json` for scripting and automation. `loop-task api` prints HTTP API endpoints.
|
|
22
|
-
- **Polished loop forms**: Task mode toggle (inline command vs. existing task), per-field validation, smart CWD defaults, clipboard copy, and direct edit navigation — across both board and TUI.
|
|
23
|
-
- **Rich log viewer**: Search/filter log output, fold chain sections, scroll lock, copy to clipboard.
|
|
24
|
-
- **Run history trends**: Sparklines of durations, success/failure streaks, average duration.
|
|
25
|
-
- **Chain visual editor**: Tree view of task chains with success/failure branches.
|
|
26
|
-
- **First-run onboarding**: Welcome screen with example loops when no loops exist.
|
|
27
|
-
- **Daemon push notifications**: Subscribe to real-time events via IPC or SSE (`GET /api/events`).
|
|
28
|
-
- **Docker support**: `docker run -v ~/.loop-cli:/root/.loop-cli loop-task`.
|
|
29
|
-
- **GitHub Actions CI**: Runs typecheck, lint, test, build on ubuntu/macos/windows.
|
|
30
|
-
|
|
31
17
|
</div>
|
|
32
18
|
|
|
33
19
|
## Loop engineering
|
package/dist/i18n/en.json
CHANGED
|
@@ -539,11 +539,11 @@
|
|
|
539
539
|
"cmdInput.searchPlaceholder": "Write something to filter about...",
|
|
540
540
|
"cmdInput.confirmYes": "yes",
|
|
541
541
|
"cmdInput.confirmCancel": "cancel",
|
|
542
|
-
"confirm.deleteLoop": "
|
|
543
|
-
"confirm.deleteTask": "
|
|
544
|
-
"confirm.deleteProject": "
|
|
545
|
-
"confirm.stopLoop": "
|
|
546
|
-
"confirm.quit": "
|
|
542
|
+
"confirm.deleteLoop": "Type yes to delete \"{name}\"",
|
|
543
|
+
"confirm.deleteTask": "Type yes to delete task \"{id}\"",
|
|
544
|
+
"confirm.deleteProject": "Type yes to delete project \"{name}\"",
|
|
545
|
+
"confirm.stopLoop": "Type yes to stop \"{name}\"",
|
|
546
|
+
"confirm.quit": "Type yes to exit loop-task",
|
|
547
547
|
"wizard.newLoop": "New loop",
|
|
548
548
|
"wizard.editLoop": "Edit loop",
|
|
549
549
|
"wizard.newTask": "New task",
|
|
@@ -560,7 +560,7 @@
|
|
|
560
560
|
"wizard.runNowNow": "Run now, then every interval",
|
|
561
561
|
"wizard.cwdPrompt": "Working directory? (optional)",
|
|
562
562
|
"wizard.cwdHint": "Directory the command runs in",
|
|
563
|
-
"wizard.descriptionPrompt": "Description?
|
|
563
|
+
"wizard.descriptionPrompt": "Description?",
|
|
564
564
|
"wizard.descriptionHint": "Short label shown in the list",
|
|
565
565
|
"wizard.maxRunsPrompt": "Max runs? (optional)",
|
|
566
566
|
"wizard.maxRunsHint": "Stop after N runs. Leave blank to run forever",
|
package/dist/tui/app.js
CHANGED
|
@@ -233,7 +233,7 @@ export function App(props) {
|
|
|
233
233
|
onConfirm: () => { void deleteTask(selectedTask.id).then(() => { void refreshTasks(); }); },
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
|
-
else if (activeTab === "projects" && selectedProjectEntity) {
|
|
236
|
+
else if (activeTab === "projects" && selectedProjectEntity && !selectedProjectEntity.isSystem) {
|
|
237
237
|
setConfirmState({
|
|
238
238
|
prompt: t("confirm.deleteProject", { name: selectedProjectEntity.name }),
|
|
239
239
|
onConfirm: async () => {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useCallback } from "react";
|
|
2
|
+
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { useAutocompleteState, } from "ink-combobox";
|
|
5
5
|
import { darkTheme as theme } from "../theme.js";
|
|
6
6
|
import { t } from "../../i18n/index.js";
|
|
7
7
|
import { buildCommands, rankCommands } from "../commands.js";
|
|
8
|
-
import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT,
|
|
8
|
+
import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, } from "../../config/constants.js";
|
|
9
9
|
import { sanitizePaste } from "../utils/paste.js";
|
|
10
10
|
export { sanitizePaste } from "../utils/paste.js";
|
|
11
11
|
// ── Rendered input with cursor ───────────────────────────────────────
|
|
@@ -76,13 +76,6 @@ function CommandDropdown({ state, rankedFiltered, }) {
|
|
|
76
76
|
}), belowCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2193 ${belowCount} more` }))] }));
|
|
77
77
|
}
|
|
78
78
|
// ── Confirm inline options ────────────────────────────────────────────
|
|
79
|
-
function ConfirmInlineOptions({ focusedIndex, yesLabel, cancelLabel, }) {
|
|
80
|
-
// Options order: [cancel(0), yes(1)] in the autocomplete state,
|
|
81
|
-
// displayed inline as "❯ yes cancel" (yes first, cancel second).
|
|
82
|
-
const yesFocused = focusedIndex === 1;
|
|
83
|
-
const cancelFocused = focusedIndex === 0;
|
|
84
|
-
return (_jsxs(Box, { paddingLeft: 3, children: [yesFocused ? (_jsx(Text, { color: theme.text.inverse, backgroundColor: theme.bg.active, children: `\u276f ${yesLabel}` })) : (_jsx(Text, { color: theme.text.muted, children: ` ${yesLabel}` })), _jsx(Text, { color: theme.text.muted, children: " " }), cancelFocused ? (_jsx(Text, { color: theme.text.inverse, backgroundColor: theme.bg.active, children: `\u276f ${cancelLabel}` })) : (_jsx(Text, { color: theme.text.muted, children: ` ${cancelLabel}` }))] }));
|
|
85
|
-
}
|
|
86
79
|
// ── Hint bar ──────────────────────────────────────────────────────────
|
|
87
80
|
function HintBar({ leftHint, rightHint, }) {
|
|
88
81
|
return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Box, { children: leftHint }), _jsx(Box, { children: rightHint })] }));
|
|
@@ -219,54 +212,39 @@ function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navO
|
|
|
219
212
|
}
|
|
220
213
|
// ── Confirm mode ─────────────────────────────────────────────────────
|
|
221
214
|
function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, }) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
const options = useMemo(() => [
|
|
225
|
-
{ label: cancelLabel, value: CONFIRM_CANCEL },
|
|
226
|
-
{ label: yesLabel, value: CONFIRM_YES },
|
|
227
|
-
], [cancelLabel, yesLabel]);
|
|
228
|
-
const handleSelect = useCallback((value) => {
|
|
229
|
-
if (value === CONFIRM_YES) {
|
|
230
|
-
onConfirmYes();
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
onConfirmCancel();
|
|
234
|
-
}
|
|
235
|
-
}, [onConfirmYes, onConfirmCancel]);
|
|
236
|
-
const { state, dispatch } = useAutocompleteState({
|
|
237
|
-
options,
|
|
238
|
-
visibleOptionCount: 2,
|
|
239
|
-
onSelect: handleSelect,
|
|
240
|
-
});
|
|
241
|
-
useInput((_input, key) => {
|
|
215
|
+
const [value, setValue] = useState("");
|
|
216
|
+
useInput((input, key) => {
|
|
242
217
|
if (key.ctrl)
|
|
243
218
|
return;
|
|
244
|
-
if (
|
|
219
|
+
if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
|
|
245
220
|
return;
|
|
246
221
|
if (key.escape) {
|
|
222
|
+
setValue("");
|
|
247
223
|
onConfirmCancel();
|
|
248
224
|
return;
|
|
249
225
|
}
|
|
250
226
|
if (key.return) {
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
227
|
+
if (value.toLowerCase() === "yes") {
|
|
228
|
+
setValue("");
|
|
229
|
+
onConfirmYes();
|
|
254
230
|
}
|
|
255
231
|
else {
|
|
232
|
+
setValue("");
|
|
256
233
|
onConfirmCancel();
|
|
257
234
|
}
|
|
258
235
|
return;
|
|
259
236
|
}
|
|
260
|
-
if (key.
|
|
261
|
-
|
|
237
|
+
if (key.backspace || key.delete) {
|
|
238
|
+
setValue((v) => v.slice(0, -1));
|
|
262
239
|
return;
|
|
263
240
|
}
|
|
264
|
-
if (key.
|
|
265
|
-
|
|
241
|
+
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
|
242
|
+
setValue((v) => v + input);
|
|
266
243
|
return;
|
|
267
244
|
}
|
|
268
245
|
}, { isActive: !disabled });
|
|
269
|
-
|
|
246
|
+
const cursor = "\x1b[7m \x1b[27m";
|
|
247
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.muted, children: confirmState.prompt + " " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { children: cursor }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
|
|
270
248
|
}
|
|
271
249
|
// ── Search mode ──────────────────────────────────────────────────────
|
|
272
250
|
function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, disabled, }) {
|
|
@@ -61,7 +61,7 @@ export function CreateView(props) {
|
|
|
61
61
|
hint: t("board.hintTask"),
|
|
62
62
|
required: true,
|
|
63
63
|
inputType: "text",
|
|
64
|
-
defaultValue: initial.taskId ?? undefined,
|
|
64
|
+
defaultValue: selectedTaskId ?? initial.taskId ?? undefined,
|
|
65
65
|
skip: (values) => !values.taskMode?.includes("Existing"),
|
|
66
66
|
onActivate: () => setTaskPickerOpen(true),
|
|
67
67
|
renderCustom: ({ isActive }) => (_jsx(SelectValueField, { label: resolvedTaskName, placeholder: t("board.chooseTask"), isActive: isActive })),
|
|
@@ -106,7 +106,7 @@ export function CreateView(props) {
|
|
|
106
106
|
key: "description",
|
|
107
107
|
prompt: t("wizard.descriptionPrompt"),
|
|
108
108
|
hint: t("wizard.descriptionHint"),
|
|
109
|
-
required:
|
|
109
|
+
required: true,
|
|
110
110
|
inputType: "text",
|
|
111
111
|
defaultValue: initial.description ?? undefined,
|
|
112
112
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { darkTheme as theme } from "../theme.js";
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
@@ -28,9 +28,22 @@ export function WizardForm(props) {
|
|
|
28
28
|
result[s.key] = values[s.key] ?? s.defaultValue ?? "";
|
|
29
29
|
return result;
|
|
30
30
|
}, [steps, values]);
|
|
31
|
+
// Synchronous mirror of resolvedValues — updated inside setValue so
|
|
32
|
+
// findNextField sees the new value immediately, before React re-renders.
|
|
33
|
+
// Without this, onChange("Existing task") + onAdvance() run in the same
|
|
34
|
+
// tick: setValues queues a state update, but findNextField reads stale
|
|
35
|
+
// resolvedValues where taskMode is still "" → taskId is wrongly skipped.
|
|
36
|
+
const resolvedValuesRef = useRef({});
|
|
37
|
+
const computeResolved = (raw) => {
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const s of steps)
|
|
40
|
+
result[s.key] = raw[s.key] ?? s.defaultValue ?? "";
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
31
43
|
const setValue = useCallback((key, next) => {
|
|
44
|
+
resolvedValuesRef.current = computeResolved({ ...resolvedValuesRef.current, [key]: next });
|
|
32
45
|
setValues((prev) => ({ ...prev, [key]: next }));
|
|
33
|
-
}, []);
|
|
46
|
+
}, [steps]);
|
|
34
47
|
const clearError = useCallback((key) => {
|
|
35
48
|
setValidationErrors((prev) => {
|
|
36
49
|
if (!(key in prev))
|
|
@@ -75,14 +88,15 @@ export function WizardForm(props) {
|
|
|
75
88
|
const findNextField = useCallback((from, delta) => {
|
|
76
89
|
let next = from + delta;
|
|
77
90
|
const len = steps.length;
|
|
91
|
+
const vals = resolvedValuesRef.current;
|
|
78
92
|
while (next >= 0 && next < len) {
|
|
79
93
|
const candidate = steps[next];
|
|
80
|
-
if (!candidate.skip || !candidate.skip(
|
|
94
|
+
if (!candidate.skip || !candidate.skip(vals))
|
|
81
95
|
return next;
|
|
82
96
|
next += delta;
|
|
83
97
|
}
|
|
84
98
|
return from;
|
|
85
|
-
}, [steps
|
|
99
|
+
}, [steps]);
|
|
86
100
|
const moveField = useCallback((delta) => {
|
|
87
101
|
if (step) {
|
|
88
102
|
const err = validateField(step.key, resolvedValues);
|
package/dist/tui/index.js
CHANGED
|
@@ -7,10 +7,12 @@ export async function launchBoard() {
|
|
|
7
7
|
// arrives as one delimited chunk instead of a stream of keypresses.
|
|
8
8
|
process.stdout.write(BRACKETED_PASTE_ENABLE);
|
|
9
9
|
const disableBracketedPaste = () => process.stdout.write(BRACKETED_PASTE_DISABLE);
|
|
10
|
-
const instance = render(React.createElement(App, {
|
|
10
|
+
const instance = render(React.createElement(App, {
|
|
11
|
+
onQuit: () => {
|
|
11
12
|
disableBracketedPaste();
|
|
12
13
|
instance.unmount();
|
|
13
|
-
}
|
|
14
|
+
}
|
|
15
|
+
}));
|
|
14
16
|
process.on("exit", disableBracketedPaste);
|
|
15
17
|
process.on("uncaughtException", (error) => {
|
|
16
18
|
console.error("Uncaught exception:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|