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 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
  [![npm version](https://img.shields.io/npm/v/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
11
13
  [![npm downloads](https://img.shields.io/npm/dm/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
12
14
  [![license](https://img.shields.io/npm/l/loop-task?style=flat-square&color=black)](./LICENSE)
13
15
  [![node](https://img.shields.io/node/v/loop-task?style=flat-square&color=black)](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": "Delete \"{name}\"? type yes to confirm",
543
- "confirm.deleteTask": "Delete task \"{id}\"? type yes to confirm",
544
- "confirm.deleteProject": "Delete project \"{name}\"? type yes to confirm",
545
- "confirm.stopLoop": "Stop \"{name}\"? type yes to confirm",
546
- "confirm.quit": "Exit loop-task? Running loops keep running in background.",
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? (optional)",
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, CONFIRM_YES, CONFIRM_CANCEL, } from "../../config/constants.js";
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 yesLabel = t("cmdInput.confirmYes");
223
- const cancelLabel = t("cmdInput.confirmCancel");
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 (_input.length > 1 && (_input.includes("\r") || _input.includes("\n")))
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 (state.isOpen && state.filteredOptions.length > 0 && state.focusedIndex < state.filteredOptions.length) {
252
- const focused = state.filteredOptions[state.focusedIndex];
253
- dispatch({ type: "SELECT", value: focused.option.value, label: focused.option.label });
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.downArrow) {
261
- dispatch({ type: "FOCUS_NEXT" });
237
+ if (key.backspace || key.delete) {
238
+ setValue((v) => v.slice(0, -1));
262
239
  return;
263
240
  }
264
- if (key.upArrow) {
265
- dispatch({ type: "FOCUS_PREV" });
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
- return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.primary, children: confirmState.prompt })] }), _jsx(ConfirmInlineOptions, { focusedIndex: state.focusedIndex, yesLabel: yesLabel, cancelLabel: cancelLabel }), _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" }) })] }));
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: false,
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(resolvedValues))
94
+ if (!candidate.skip || !candidate.skip(vals))
81
95
  return next;
82
96
  next += delta;
83
97
  }
84
98
  return from;
85
- }, [steps, values]);
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, { onQuit: () => {
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.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": {