loop-task 1.4.2 → 1.4.4

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/dist/board/App.js CHANGED
@@ -345,7 +345,7 @@ export function App(props) {
345
345
  }
346
346
  else {
347
347
  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") })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel })) : 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; } })) : (_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) {
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; } })) : (_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
349
  setEditTarget(loop);
350
350
  push("create");
351
351
  } } }), _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 })] }));
@@ -86,72 +86,65 @@ export function CreateView(props) {
86
86
  if (key.name === "tab") {
87
87
  setFocusIndex((i) => {
88
88
  const next = key.shift ? i - 1 : i + 1;
89
- return Math.max(0, Math.min(cancelIndex, next));
89
+ if (next < 0)
90
+ return cancelIndex;
91
+ if (next > cancelIndex)
92
+ return 0;
93
+ return next;
90
94
  });
95
+ key.preventDefault();
91
96
  return;
92
97
  }
93
- if (key.name === "left" || key.name === "right") {
94
- if (focusIndex === saveIndex) {
95
- setFocusIndex(key.name === "right" ? cancelIndex : focusIndex - 1 >= 0 ? filteredFields.length - 1 : 0);
96
- return;
97
- }
98
- if (focusIndex === cancelIndex) {
99
- setFocusIndex(key.name === "right" ? 0 : saveIndex);
100
- return;
101
- }
102
- if (key.name === "right") {
103
- if (focusIndex + 1 < filteredFields.length) {
104
- setFocusIndex((i) => i + 1);
105
- }
106
- else {
107
- setFocusIndex(saveIndex);
108
- }
109
- return;
110
- }
111
- if (key.name === "left") {
112
- if (focusIndex > 0) {
113
- setFocusIndex((i) => i - 1);
114
- }
115
- else {
116
- setFocusIndex(cancelIndex);
117
- }
98
+ if (key.name === "up" || key.name === "down") {
99
+ const field = filteredFields[focusIndex];
100
+ if (field === "project" && projectOptions.length > 1) {
101
+ const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
102
+ const nextIdx = key.name === "down"
103
+ ? (currentIdx + 1) % projectOptions.length
104
+ : (currentIdx - 1 + projectOptions.length) % projectOptions.length;
105
+ const next = projectOptions[nextIdx];
106
+ if (next)
107
+ updateValues({ ...valuesRef.current, project: next.value });
108
+ key.preventDefault();
118
109
  return;
119
110
  }
111
+ setFocusIndex((i) => {
112
+ const next = key.name === "up" ? i - 1 : i + 1;
113
+ if (next < 0)
114
+ return cancelIndex;
115
+ if (next > cancelIndex)
116
+ return 0;
117
+ return next;
118
+ });
119
+ key.preventDefault();
120
+ return;
120
121
  }
121
122
  if (key.name === "return" || key.name === "enter" || key.name === " ") {
122
123
  const field = filteredFields[focusIndex];
123
124
  if (field === "taskMode") {
124
125
  const next = valuesRef.current.taskMode === TASK_MODE_INLINE ? TASK_MODE_EXISTING : TASK_MODE_INLINE;
125
126
  updateValues({ ...valuesRef.current, taskMode: next });
127
+ key.preventDefault();
126
128
  return;
127
129
  }
128
130
  if (field === "runNow") {
129
131
  const next = valuesRef.current.runNow === "y" ? "n" : "y";
130
132
  updateValues({ ...valuesRef.current, runNow: next });
133
+ key.preventDefault();
131
134
  return;
132
135
  }
133
136
  if (focusIndex === chooseTaskIdx && !isInline) {
134
137
  props.onChooseTask();
138
+ key.preventDefault();
135
139
  return;
136
140
  }
137
141
  if (focusIndex === saveIndex) {
138
142
  void submit(valuesRef.current);
143
+ key.preventDefault();
139
144
  }
140
145
  else if (focusIndex === cancelIndex) {
141
146
  props.onCancel();
142
- }
143
- }
144
- if (key.name === "up" || key.name === "down") {
145
- const field = filteredFields[focusIndex];
146
- if (field === "project" && projectOptions.length > 1) {
147
- const currentIdx = projectOptions.findIndex((p) => p.value === valuesRef.current.project);
148
- const nextIdx = key.name === "down"
149
- ? (currentIdx + 1) % projectOptions.length
150
- : (currentIdx - 1 + projectOptions.length) % projectOptions.length;
151
- const next = projectOptions[nextIdx];
152
- if (next)
153
- updateValues({ ...valuesRef.current, project: next.value });
154
- return;
147
+ key.preventDefault();
155
148
  }
156
149
  }
157
150
  });
@@ -1,11 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
2
  import { t } from "../../i18n/index.js";
3
3
  import { useHoverState } from "../hooks/useHoverState.js";
4
4
  import { HOVER_BG } from "../../config/constants.js";
5
+ import { SearchBox } from "./SearchBox.js";
5
6
  export function FilterBar(props) {
6
- const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName } = props;
7
+ const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName, onQueryChange, onSearchActivate, onSearchDismiss } = props;
7
8
  const statusDisplay = filters.status === "waiting" ? "waiting" : filters.status;
8
- return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: [filters.query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: filters.query ? "#e5e7eb" : "#6b7280", children: filters.query || t("board.searchEmpty") })) }), onSelectProject ? (_jsx(ClickableBadge, { title: t("project.filterTitle"), text: currentProjectName ?? "Default", textColor: "#f59e0b", focused: focusedPanel === "project-filter", onMouseDown: onSelectProject, marginRight: 1 })) : null, _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle })] }));
9
+ return (_jsxs("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(SearchBox, { query: filters.query, searchActive: searchActive, focused: focusedPanel === "search", onQueryChange: onQueryChange, onActivate: onSearchActivate, onDismiss: onSearchDismiss }), onSelectProject ? (_jsx(ClickableBadge, { title: t("project.filterTitle"), text: currentProjectName ?? "Default", textColor: "#f59e0b", focused: focusedPanel === "project-filter", onMouseDown: onSelectProject, marginRight: 1 })) : null, _jsx(ClickableBadge, { title: t("board.statusFilterTitle"), text: statusDisplay, textColor: "#38bdf8", focused: focusedPanel === "status", onMouseDown: onStatusCycle, marginRight: 1 }), _jsx(ClickableBadge, { title: t("board.sortTitle"), text: sort, textColor: "#a3e635", focused: focusedPanel === "sort", onMouseDown: onSortCycle })] }));
9
10
  }
10
11
  function ClickableBadge(props) {
11
12
  const { isHovered, hoverProps } = useHoverState();
@@ -5,40 +5,43 @@ export function HelpModal(props) {
5
5
  const { view } = props;
6
6
  const { width } = useTerminalDimensions();
7
7
  const loopRows = [
8
+ ["tab/←→", "switch panels"],
9
+ ["↑/↓", "navigate list items"],
10
+ ["enter", t("board.helpEdit")],
8
11
  ["e", "edit loop"],
9
12
  ["d/del", "delete loop"],
10
13
  ["c", "clone loop"],
11
14
  ["f", "force run"],
12
15
  ["p", "pause/play (contextual)"],
13
16
  ["s", "stop loop (resets schedule)"],
14
- [t("board.helpKeyEnter"), t("board.helpEdit")],
15
17
  ["n", t("board.helpCreate")],
16
18
  ["t", t("board.helpCreateTask")],
17
19
  ["o", t("board.helpCycleSort")],
18
20
  ["x", "cycle status filter"],
19
21
  ["r", "project filter"],
20
- ["←/→", t("board.helpSwitchPanel")],
21
22
  ["/", t("board.helpSearch")],
22
23
  ["h", t("board.helpToggleHelp")],
23
24
  ["esc", t("board.helpQuit")],
24
25
  ];
25
26
  const taskRows = [
27
+ ["tab/←→", "switch panels"],
28
+ ["↑/↓", "navigate tasks"],
29
+ ["enter", "focus actions panel"],
26
30
  ["e", "edit task"],
27
31
  ["d/del", "delete task"],
28
32
  ["s", "select task (when creating/editing a loop)"],
29
- ["enter", "focus actions panel"],
30
33
  ["n", t("board.helpCreateTask")],
31
- ["←/→", t("board.helpSwitchPanel")],
32
34
  ["/", t("board.helpSearch")],
33
35
  ["h", t("board.helpToggleHelp")],
34
36
  ["esc", t("board.helpQuit")],
35
37
  ];
36
38
  const projectRows = [
39
+ ["tab/←→", "switch panel (list/actions)"],
40
+ ["↑/↓", "navigate projects"],
37
41
  ["enter", "focus actions"],
38
42
  ["n", "new project"],
39
43
  ["e", "edit project"],
40
44
  ["d", "delete project"],
41
- ["←/→", "switch panel (list/actions)"],
42
45
  ["h", t("board.helpToggleHelp")],
43
46
  ["esc", t("board.helpQuit")],
44
47
  ];
@@ -41,6 +41,7 @@ export function ProjectsPage(props) {
41
41
  setSelectedIndex((i) => Math.max(0, i - 1));
42
42
  else
43
43
  setSelectedAction((a) => Math.max(0, a - 1));
44
+ key.preventDefault();
44
45
  return;
45
46
  }
46
47
  if (key.name === "down") {
@@ -48,10 +49,12 @@ export function ProjectsPage(props) {
48
49
  setSelectedIndex((i) => Math.min(projects.length - 1, i + 1));
49
50
  else
50
51
  setSelectedAction((a) => Math.min(PROJECT_ACTION_COUNT - 1, a + 1));
52
+ key.preventDefault();
51
53
  return;
52
54
  }
53
- if (key.name === "left" || key.name === "right") {
55
+ if (key.name === "left" || key.name === "right" || key.name === "tab") {
54
56
  setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
57
+ key.preventDefault();
55
58
  return;
56
59
  }
57
60
  if (key.name === "return" || key.name === "enter") {
@@ -61,22 +64,27 @@ export function ProjectsPage(props) {
61
64
  else {
62
65
  runAction(selectedAction);
63
66
  }
67
+ key.preventDefault();
64
68
  return;
65
69
  }
66
70
  if (key.name === "n") {
67
71
  setSubModal("create");
72
+ key.preventDefault();
68
73
  return;
69
74
  }
70
75
  if (key.name === "e" && selectedProject && !selectedProject.isSystem) {
71
76
  setSubModal("edit");
77
+ key.preventDefault();
72
78
  return;
73
79
  }
74
80
  if (key.name === "d" && selectedProject && !selectedProject.isSystem) {
75
81
  setSubModal("delete");
82
+ key.preventDefault();
76
83
  return;
77
84
  }
78
85
  if (key.name === "escape") {
79
86
  onClose();
87
+ key.preventDefault();
80
88
  }
81
89
  });
82
90
  const actions = [
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ export function SearchBox(props) {
4
+ const { query, searchActive, focused, onQueryChange, onActivate, onDismiss } = props;
5
+ 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", { 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
+ }
@@ -1,6 +1,6 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
- import { t } from "../../i18n/index.js";
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { SearchBox } from "./SearchBox.js";
3
3
  export function TaskFilterBar(props) {
4
- const { query, searchActive, focusedPanel } = props;
5
- return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { flexGrow: 1, height: 3, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: query ? "#e5e7eb" : "#6b7280", children: [query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", children: query || t("board.searchEmpty") })) }) }));
4
+ const { query, searchActive, focusedPanel, onQueryChange, onSearchActivate, onSearchDismiss } = props;
5
+ return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { style: { flexGrow: 1 }, children: _jsx(SearchBox, { query: query, searchActive: searchActive, focused: focusedPanel === "search", onQueryChange: onQueryChange, onActivate: onSearchActivate, onDismiss: onSearchDismiss }) }) }));
6
6
  }
@@ -40,44 +40,35 @@ export function TaskForm(props) {
40
40
  if (key.name === "tab") {
41
41
  setFocusIndex((i) => {
42
42
  const next = key.shift ? i - 1 : i + 1;
43
- return Math.max(0, Math.min(cancelIndex, next));
43
+ if (next < 0)
44
+ return cancelIndex;
45
+ if (next > cancelIndex)
46
+ return 0;
47
+ return next;
44
48
  });
49
+ key.preventDefault();
45
50
  return;
46
51
  }
47
- if (key.name === "left" || key.name === "right") {
48
- if (focusIndex === saveIndex) {
49
- setFocusIndex(key.name === "right" ? cancelIndex : taskFields.length - 1);
50
- return;
51
- }
52
- if (focusIndex === cancelIndex) {
53
- setFocusIndex(key.name === "right" ? 0 : saveIndex);
54
- return;
55
- }
56
- if (key.name === "right") {
57
- if (focusIndex + 1 < taskFields.length) {
58
- setFocusIndex((i) => i + 1);
59
- }
60
- else {
61
- setFocusIndex(saveIndex);
62
- }
63
- return;
64
- }
65
- if (key.name === "left") {
66
- if (focusIndex > 0) {
67
- setFocusIndex((i) => i - 1);
68
- }
69
- else {
70
- setFocusIndex(cancelIndex);
71
- }
72
- return;
73
- }
52
+ if (key.name === "up" || key.name === "down") {
53
+ setFocusIndex((i) => {
54
+ const next = key.name === "up" ? i - 1 : i + 1;
55
+ if (next < 0)
56
+ return cancelIndex;
57
+ if (next > cancelIndex)
58
+ return 0;
59
+ return next;
60
+ });
61
+ key.preventDefault();
62
+ return;
74
63
  }
75
64
  if (key.name === "return" || key.name === "enter") {
76
65
  if (focusIndex === saveIndex) {
77
66
  void submit(valuesRef.current);
67
+ key.preventDefault();
78
68
  }
79
69
  else if (focusIndex === cancelIndex) {
80
70
  props.onCancel();
71
+ key.preventDefault();
81
72
  }
82
73
  }
83
74
  });
@@ -200,63 +200,63 @@ export function useBoardKeybindings(params) {
200
200
  const name = key.name;
201
201
  if (confirm) {
202
202
  CONFIRM_KEYS[name]?.({ confirm, confirmChoice, setConfirm, setConfirmChoice });
203
+ key.preventDefault();
203
204
  return;
204
205
  }
205
206
  if (logModalRun) {
206
207
  if (name === "escape" || name === "q") {
207
208
  OVERLAY_DISMISS.log({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
209
+ key.preventDefault();
208
210
  return;
209
211
  }
210
212
  if (key.ctrl && name === "c") {
211
213
  copyToClipboard(logModalLines.join("\n"));
214
+ key.preventDefault();
212
215
  return;
213
216
  }
214
217
  return;
215
218
  }
216
219
  if (helpOpen && (name === "h" || name === "escape")) {
217
220
  OVERLAY_DISMISS.help({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
221
+ key.preventDefault();
218
222
  return;
219
223
  }
220
224
  if (helpOpen)
221
225
  return;
222
- if (searchActive && (name === "escape" || name === "return" || name === "enter")) {
223
- OVERLAY_DISMISS.search({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
224
- return;
225
- }
226
226
  if (searchActive) {
227
- if (name === "left") {
227
+ if (name === "escape") {
228
228
  setSearchActive(false);
229
- setFocusedPanel("actions");
230
- setSelectedAction((selected ? getActionCount(selected.status) : 0) - 1);
231
- return;
232
- }
233
- if (name === "backspace") {
234
- setFilters((prev) => ({ ...prev, query: prev.query.slice(0, -1) }));
235
- return;
236
- }
237
- if (key.sequence && key.sequence.length === 1 && key.sequence >= " " && key.sequence <= "~") {
238
- setFilters((prev) => ({ ...prev, query: prev.query + key.sequence }));
229
+ setFocusedPanel("loops");
230
+ key.preventDefault();
239
231
  return;
240
232
  }
241
233
  return;
242
234
  }
243
- if (name === "left" || name === "right") {
244
- if (focusedPanel === "actions") {
235
+ if (view !== "board" && name === "escape") {
236
+ VIEW_ESCAPE[view]?.({ setEditTarget, setEditTask, onBack: pop });
237
+ key.preventDefault();
238
+ return;
239
+ }
240
+ if (view !== "board")
241
+ return;
242
+ if (name === "left" || name === "right" || name === "tab") {
243
+ const direction = name === "left" || (name === "tab" && key.shift) ? "left" : "right";
244
+ if (focusedPanel === "actions" && name !== "tab") {
245
245
  const actionCount = selected ? getActionCount(selected.status) : 0;
246
- if (name === "left" && selectedAction === 0) {
246
+ if (direction === "left" && selectedAction === 0) {
247
247
  setFocusedPanel((p) => nextPanel(p, "left"));
248
248
  }
249
- else if (name === "right" && selectedAction === actionCount - 1) {
249
+ else if (direction === "right" && selectedAction === actionCount - 1) {
250
250
  setFocusedPanel((p) => nextPanel(p, "right"));
251
251
  }
252
252
  else {
253
- setSelectedAction((i) => name === "right"
253
+ setSelectedAction((i) => direction === "right"
254
254
  ? Math.min(actionCount - 1, i + 1)
255
255
  : Math.max(0, i - 1));
256
256
  }
257
257
  }
258
258
  else {
259
- const next = nextPanel(focusedPanel, name === "right" ? "right" : "left");
259
+ const next = nextPanel(focusedPanel, direction);
260
260
  if (next === "actions") {
261
261
  setFocusedPanel("actions");
262
262
  setSelectedAction(0);
@@ -265,17 +265,13 @@ export function useBoardKeybindings(params) {
265
265
  setFocusedPanel(next);
266
266
  }
267
267
  }
268
+ key.preventDefault();
268
269
  return;
269
270
  }
270
- if (view !== "board" && name === "escape") {
271
- VIEW_ESCAPE[view]?.({ setEditTarget, setEditTask, onBack: pop });
272
- return;
273
- }
274
- if (view !== "board")
275
- return;
276
271
  const globalHandler = GLOBAL_KEYS[name];
277
272
  if (globalHandler) {
278
273
  globalHandler({ destroyLogSocket, onQuit, setHelpOpen, setEditTarget, setEditTask, push, onAction });
274
+ key.preventDefault();
279
275
  return;
280
276
  }
281
277
  const panelHandler = panelHandlers[focusedPanel];
@@ -284,8 +280,10 @@ export function useBoardKeybindings(params) {
284
280
  setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
285
281
  selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
286
282
  refreshTasks, onViewTasks, onViewProjects, onAddLoop,
287
- }))
283
+ })) {
284
+ key.preventDefault();
288
285
  return;
286
+ }
289
287
  const shortcut = BOARD_SHORTCUTS[name];
290
288
  if (shortcut) {
291
289
  shortcut({
@@ -294,6 +292,7 @@ export function useBoardKeybindings(params) {
294
292
  selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
295
293
  onSelectProject,
296
294
  });
295
+ key.preventDefault();
297
296
  }
298
297
  });
299
298
  }
@@ -16,113 +16,108 @@ export function useTaskKeybindings(params) {
16
16
  if (name === "escape") {
17
17
  setTaskSearchActive(false);
18
18
  setTaskFocusedPanel("tasks");
19
- return;
20
- }
21
- if (name === "return" || name === "enter") {
22
- setTaskSearchActive(false);
23
- setTaskFocusedPanel("tasks");
24
- return;
25
- }
26
- if (name === "left") {
27
- setTaskSearchActive(false);
28
- setTaskFocusedPanel("actions");
29
- setTaskSelectedAction(taskActionCount - 1);
30
- return;
31
- }
32
- if (name === "backspace") {
33
- setTaskQuery((q) => q.slice(0, -1));
34
- return;
35
- }
36
- if (key.sequence && key.sequence.length === 1 && key.sequence >= " " && key.sequence <= "~") {
37
- setTaskQuery((q) => q + key.sequence);
19
+ key.preventDefault();
38
20
  return;
39
21
  }
40
22
  return;
41
23
  }
42
24
  if (name === "escape") {
43
25
  onCancel();
26
+ key.preventDefault();
44
27
  return;
45
28
  }
46
29
  if (name === "n") {
47
30
  onCreateTask();
31
+ key.preventDefault();
48
32
  return;
49
33
  }
50
34
  if (name === "/" && taskFocusedPanel === "tasks") {
51
35
  setTaskSearchActive(true);
36
+ key.preventDefault();
52
37
  return;
53
38
  }
54
- if (name === "tab") {
55
- setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
56
- return;
57
- }
58
- if (name === "left" || name === "right") {
59
- if (taskFocusedPanel === "actions") {
60
- if (name === "left" && taskSelectedAction === 0) {
39
+ if (name === "tab" || name === "left" || name === "right") {
40
+ const direction = name === "left" || (name === "tab" && key.shift) ? "left" : "right";
41
+ if (taskFocusedPanel === "actions" && name !== "tab") {
42
+ if (direction === "left" && taskSelectedAction === 0) {
61
43
  setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
62
44
  }
63
- else if (name === "right" && taskSelectedAction === taskActionCount - 1) {
45
+ else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
64
46
  setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
65
47
  }
66
48
  else {
67
- setTaskSelectedAction((i) => name === "right"
49
+ setTaskSelectedAction((i) => direction === "right"
68
50
  ? Math.min(taskActionCount - 1, i + 1)
69
51
  : Math.max(0, i - 1));
70
52
  }
71
53
  }
72
54
  else {
73
- setTaskFocusedPanel((p) => nextTaskPanel(p, name === "right" ? "right" : "left"));
55
+ setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
74
56
  }
57
+ key.preventDefault();
75
58
  return;
76
59
  }
77
60
  if (taskFocusedPanel === "tasks") {
78
61
  if (name === "up" || name === "k") {
79
62
  setTaskSelectedIndex((i) => Math.max(0, i - 1));
63
+ key.preventDefault();
80
64
  return;
81
65
  }
82
66
  if (name === "down" || name === "j") {
83
67
  setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
68
+ key.preventDefault();
84
69
  return;
85
70
  }
86
71
  if (name === "return" || name === "enter") {
87
72
  setTaskFocusedPanel("actions");
73
+ key.preventDefault();
88
74
  return;
89
75
  }
90
76
  if (name === "e" && tasks[taskSelectedIndex]) {
91
77
  onTaskAction("edit");
78
+ key.preventDefault();
92
79
  return;
93
80
  }
94
81
  if (name === "d" && tasks[taskSelectedIndex]) {
95
82
  onTaskAction("delete");
83
+ key.preventDefault();
96
84
  return;
97
85
  }
98
86
  if (name === "delete" && tasks[taskSelectedIndex]) {
99
87
  onTaskAction("delete");
88
+ key.preventDefault();
100
89
  return;
101
90
  }
102
91
  if (selectable && name === "s" && tasks[taskSelectedIndex]) {
103
92
  onTaskAction("select");
93
+ key.preventDefault();
104
94
  return;
105
95
  }
106
96
  }
107
97
  if (taskFocusedPanel === "actions") {
108
98
  if (name === "up" || name === "k") {
109
99
  setTaskSelectedAction((i) => Math.max(0, i - 1));
100
+ key.preventDefault();
110
101
  return;
111
102
  }
112
103
  if (name === "down" || name === "j") {
113
104
  setTaskSelectedAction((i) => Math.min(taskActionCount - 1, i + 1));
105
+ key.preventDefault();
114
106
  return;
115
107
  }
116
108
  if (name === "return" || name === "enter") {
117
109
  onTaskAction(taskActions[taskSelectedAction] ?? "select");
110
+ key.preventDefault();
118
111
  return;
119
112
  }
120
113
  if (name === "d") {
121
114
  onTaskAction("delete");
115
+ key.preventDefault();
122
116
  return;
123
117
  }
124
118
  if (selectable && name === "s") {
125
119
  onTaskAction("select");
120
+ key.preventDefault();
126
121
  return;
127
122
  }
128
123
  }
@@ -21,6 +21,15 @@ export function loopFile(id) {
21
21
  export function taskFile(id) {
22
22
  return path.join(getTasksDir(), `${id}.json`);
23
23
  }
24
+ export function loopsJson() {
25
+ return path.join(getDataDir(), "loops.json");
26
+ }
27
+ export function tasksJson() {
28
+ return path.join(getDataDir(), "tasks.json");
29
+ }
30
+ export function projectsJson() {
31
+ return path.join(getDataDir(), "projects.json");
32
+ }
24
33
  export function logFile(id) {
25
34
  return path.join(getLogsDir(), `${id}.log`);
26
35
  }
@@ -335,12 +335,15 @@ export class LoopController extends EventEmitter {
335
335
  }
336
336
  const chainTargetId = result.exitCode === 0 ? task?.onSuccessTaskId : task?.onFailureTaskId;
337
337
  if (chainTargetId) {
338
- const chainTask = this.taskResolver(chainTargetId);
339
- if (chainTask) {
340
- const chainGroupId = crypto.randomUUID().slice(0, 8);
341
- const mainRecord = this.runHistory[this.runHistory.length - 1];
342
- if (mainRecord)
343
- mainRecord.chainGroupId = chainGroupId;
338
+ const chainGroupId = crypto.randomUUID().slice(0, 8);
339
+ const mainRecord = this.runHistory[this.runHistory.length - 1];
340
+ if (mainRecord)
341
+ mainRecord.chainGroupId = chainGroupId;
342
+ let currentTargetId = chainTargetId;
343
+ while (currentTargetId) {
344
+ const chainTask = this.taskResolver(currentTargetId);
345
+ if (!chainTask)
346
+ break;
344
347
  const chainStartedAt = new Date().toISOString();
345
348
  const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
346
349
  this.runHistory.push({
@@ -356,7 +359,7 @@ export class LoopController extends EventEmitter {
356
359
  });
357
360
  const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, this.options.cwd, this.logStream, signal, this.runCount);
358
361
  const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
359
- const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running");
362
+ const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
360
363
  if (chainRecord) {
361
364
  chainRecord.exitCode = chainResult.exitCode;
362
365
  chainRecord.duration = chainResult.duration;
@@ -365,6 +368,7 @@ export class LoopController extends EventEmitter {
365
368
  }
366
369
  this.lastExitCode = chainResult.exitCode;
367
370
  this.lastDuration = (this.lastDuration ?? 0) + chainResult.duration;
371
+ currentTargetId = (chainResult.exitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
368
372
  }
369
373
  }
370
374
  if (this.runHistory.length > 50) {
@@ -1,11 +1,13 @@
1
1
  import { LoopManager } from "./manager.js";
2
2
  import { TaskManager } from "./task-manager.js";
3
3
  import { IpcServer } from "./server.js";
4
- import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, } from "./state.js";
4
+ import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, } from "./state.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { daemonLog } from "./daemon-log.js";
7
7
  async function main() {
8
8
  const taskManager = new TaskManager();
9
+ migrateLoopsToJson();
10
+ migrateTasksToJson();
9
11
  taskManager.init();
10
12
  const manager = new LoopManager(taskManager);
11
13
  const server = new IpcServer(manager, taskManager);
@@ -1,53 +1,76 @@
1
1
  import fs from "node:fs";
2
2
  import { writeFileAtomic } from "../shared/fs-utils.js";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
3
  import { daemonLog } from "./daemon-log.js";
6
4
  import crypto from "node:crypto";
5
+ import { getDataDir, projectsJson } from "../config/paths.js";
6
+ import path from "node:path";
7
7
  export class ProjectManager {
8
8
  projects = new Map();
9
- projectsDir;
10
- constructor(loopCliHome) {
11
- const baseDir = loopCliHome || join(homedir(), ".loop-cli");
12
- this.projectsDir = join(baseDir, "projects");
13
- }
14
9
  init() {
15
- if (!fs.existsSync(this.projectsDir)) {
16
- fs.mkdirSync(this.projectsDir, { recursive: true });
17
- }
10
+ const dataDir = getDataDir();
11
+ fs.mkdirSync(dataDir, { recursive: true });
12
+ this.migrateProjectsToJson();
18
13
  this.loadProjects();
19
14
  if (!this.projects.has("default")) {
20
15
  this.createDefaultProject();
21
16
  }
22
17
  }
23
- loadProjects() {
24
- this.projects.clear();
25
- if (!fs.existsSync(this.projectsDir))
18
+ migrateProjectsToJson() {
19
+ const jsonFile = projectsJson();
20
+ if (fs.existsSync(jsonFile))
21
+ return;
22
+ const oldDir = path.join(getDataDir(), "projects");
23
+ if (!fs.existsSync(oldDir))
26
24
  return;
27
25
  try {
28
- const files = fs.readdirSync(this.projectsDir);
26
+ const files = fs.readdirSync(oldDir).filter((f) => f.endsWith(".json"));
27
+ if (files.length === 0)
28
+ return;
29
+ const projects = [];
29
30
  for (const file of files) {
30
- if (!file.endsWith(".json"))
31
- continue;
32
31
  try {
33
- const raw = fs.readFileSync(join(this.projectsDir, file), "utf-8");
32
+ const raw = fs.readFileSync(path.join(oldDir, file), "utf-8");
34
33
  const content = JSON.parse(raw);
35
34
  if (content.id)
36
- this.projects.set(content.id, content);
35
+ projects.push(content);
37
36
  }
38
- catch (err) {
39
- daemonLog(`Failed to load project ${file}: ${err}`);
37
+ catch {
38
+ // skip corrupted files
40
39
  }
41
40
  }
41
+ writeFileAtomic(jsonFile, JSON.stringify(projects, null, 2));
42
+ daemonLog(`migrated ${projects.length} project(s) to projects.json`);
42
43
  }
43
44
  catch (err) {
44
- daemonLog(`Failed to load projects directory: ${err}`);
45
+ daemonLog(`Failed to migrate projects: ${err}`);
45
46
  }
46
47
  }
48
+ loadProjects() {
49
+ this.projects.clear();
50
+ const jsonFile = projectsJson();
51
+ if (!fs.existsSync(jsonFile))
52
+ return;
53
+ try {
54
+ const raw = fs.readFileSync(jsonFile, "utf-8");
55
+ const parsed = JSON.parse(raw);
56
+ if (!Array.isArray(parsed))
57
+ return;
58
+ for (const item of parsed) {
59
+ if (item && item.id)
60
+ this.projects.set(item.id, item);
61
+ }
62
+ }
63
+ catch (err) {
64
+ daemonLog(`Failed to load projects.json: ${err}`);
65
+ }
66
+ }
67
+ saveAllProjects() {
68
+ const all = Array.from(this.projects.values());
69
+ writeFileAtomic(projectsJson(), JSON.stringify(all, null, 2));
70
+ }
47
71
  saveProject(project) {
48
- const filePath = join(this.projectsDir, `${project.id}.json`);
49
- writeFileAtomic(filePath, JSON.stringify(project, null, 2));
50
72
  this.projects.set(project.id, project);
73
+ this.saveAllProjects();
51
74
  }
52
75
  createDefaultProject() {
53
76
  const defaultProject = {
@@ -96,9 +119,7 @@ export class ProjectManager {
96
119
  throw new Error(`Project ${id} not found`);
97
120
  if (project.isSystem)
98
121
  throw new Error("Cannot delete system project");
99
- const filePath = join(this.projectsDir, `${id}.json`);
100
- if (fs.existsSync(filePath))
101
- fs.rmSync(filePath);
102
122
  this.projects.delete(id);
123
+ this.saveAllProjects();
103
124
  }
104
125
  }
@@ -3,27 +3,39 @@ import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { removeIfExists, writeFileAtomic } from "../shared/fs-utils.js";
6
- import { getLoopsDir, getTasksDir, getLogsDir, loopFile, taskFile, logFile, getPidFile, getSignatureFile, getSocketPath, } from "../config/paths.js";
6
+ import { getLoopsDir, getTasksDir, getLogsDir, logFile, getPidFile, getSignatureFile, getSocketPath, loopsJson, tasksJson, getDataDir, } from "../config/paths.js";
7
7
  export { getDataDir, getPidFile, getSocketPath } from "../config/paths.js";
8
8
  function ensureDirs() {
9
- fs.mkdirSync(getLoopsDir(), { recursive: true });
9
+ fs.mkdirSync(getDataDir(), { recursive: true });
10
10
  fs.mkdirSync(getLogsDir(), { recursive: true });
11
11
  }
12
- export function saveLoop(meta) {
13
- ensureDirs();
14
- writeFileAtomic(loopFile(meta.id), JSON.stringify(meta, null, 2));
12
+ function readJsonArray(filePath) {
13
+ if (!fs.existsSync(filePath))
14
+ return [];
15
+ try {
16
+ const raw = fs.readFileSync(filePath, "utf-8");
17
+ const parsed = JSON.parse(raw);
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ }
20
+ catch {
21
+ return [];
22
+ }
15
23
  }
16
- export function loadLoop(id) {
17
- const file = loopFile(id);
18
- if (!fs.existsSync(file))
19
- return null;
20
- const raw = fs.readFileSync(file, "utf-8");
21
- return JSON.parse(raw);
24
+ function writeJsonArray(filePath, items) {
25
+ ensureDirs();
26
+ writeFileAtomic(filePath, JSON.stringify(items, null, 2));
22
27
  }
23
- export function loadAllLoops() {
28
+ export function migrateLoopsToJson() {
24
29
  ensureDirs();
30
+ const jsonFile = loopsJson();
31
+ if (fs.existsSync(jsonFile))
32
+ return;
25
33
  const dir = getLoopsDir();
34
+ if (!fs.existsSync(dir))
35
+ return;
26
36
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
37
+ if (files.length === 0)
38
+ return;
27
39
  const loops = [];
28
40
  for (const file of files) {
29
41
  try {
@@ -34,28 +46,20 @@ export function loadAllLoops() {
34
46
  // skip corrupted files
35
47
  }
36
48
  }
37
- return loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
38
- }
39
- export function deleteLoop(id) {
40
- removeIfExists(loopFile(id));
41
- removeIfExists(logFile(id));
42
- }
43
- export function saveTask(task) {
44
- const dir = getTasksDir();
45
- fs.mkdirSync(dir, { recursive: true });
46
- writeFileAtomic(taskFile(task.id), JSON.stringify(task, null, 2));
47
- }
48
- export function loadTask(id) {
49
- const file = taskFile(id);
50
- if (!fs.existsSync(file))
51
- return null;
52
- const raw = fs.readFileSync(file, "utf-8");
53
- return JSON.parse(raw);
49
+ loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
50
+ writeJsonArray(jsonFile, loops);
54
51
  }
55
- export function loadAllTasks() {
52
+ export function migrateTasksToJson() {
53
+ ensureDirs();
54
+ const jsonFile = tasksJson();
55
+ if (fs.existsSync(jsonFile))
56
+ return;
56
57
  const dir = getTasksDir();
57
- fs.mkdirSync(dir, { recursive: true });
58
+ if (!fs.existsSync(dir))
59
+ return;
58
60
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
61
+ if (files.length === 0)
62
+ return;
59
63
  const tasks = [];
60
64
  for (const file of files) {
61
65
  try {
@@ -66,10 +70,57 @@ export function loadAllTasks() {
66
70
  // skip corrupted files
67
71
  }
68
72
  }
73
+ tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
74
+ writeJsonArray(jsonFile, tasks);
75
+ }
76
+ export function saveLoop(meta) {
77
+ const loops = readJsonArray(loopsJson());
78
+ const idx = loops.findIndex((l) => l.id === meta.id);
79
+ if (idx >= 0)
80
+ loops[idx] = meta;
81
+ else
82
+ loops.push(meta);
83
+ writeJsonArray(loopsJson(), loops);
84
+ }
85
+ export function loadLoop(id) {
86
+ const loops = readJsonArray(loopsJson());
87
+ return loops.find((l) => l.id === id) ?? null;
88
+ }
89
+ export function loadAllLoops() {
90
+ const loops = readJsonArray(loopsJson());
91
+ return loops.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
92
+ }
93
+ export function deleteLoop(id) {
94
+ const loops = readJsonArray(loopsJson());
95
+ const filtered = loops.filter((l) => l.id !== id);
96
+ if (filtered.length !== loops.length) {
97
+ writeJsonArray(loopsJson(), filtered);
98
+ }
99
+ removeIfExists(logFile(id));
100
+ }
101
+ export function saveTask(task) {
102
+ const tasks = readJsonArray(tasksJson());
103
+ const idx = tasks.findIndex((t) => t.id === task.id);
104
+ if (idx >= 0)
105
+ tasks[idx] = task;
106
+ else
107
+ tasks.push(task);
108
+ writeJsonArray(tasksJson(), tasks);
109
+ }
110
+ export function loadTask(id) {
111
+ const tasks = readJsonArray(tasksJson());
112
+ return tasks.find((t) => t.id === id) ?? null;
113
+ }
114
+ export function loadAllTasks() {
115
+ const tasks = readJsonArray(tasksJson());
69
116
  return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
70
117
  }
71
118
  export function deleteTask(id) {
72
- removeIfExists(taskFile(id));
119
+ const tasks = readJsonArray(tasksJson());
120
+ const filtered = tasks.filter((t) => t.id !== id);
121
+ if (filtered.length !== tasks.length) {
122
+ writeJsonArray(tasksJson(), filtered);
123
+ }
73
124
  }
74
125
  export function getLogPath(id) {
75
126
  ensureDirs();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
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": {