loop-task 1.1.0 → 1.4.0

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.
Files changed (130) hide show
  1. package/README.md +250 -113
  2. package/dist/board/App.js +340 -0
  3. package/dist/board/board-log.js +18 -0
  4. package/dist/board/components/ActionButtons.js +45 -0
  5. package/dist/board/components/BoardButton.js +58 -0
  6. package/dist/board/components/ConfirmModal.js +36 -0
  7. package/dist/board/components/CreateForm.js +309 -0
  8. package/dist/board/components/CreateProjectModal.js +104 -0
  9. package/dist/board/components/DeleteProjectConfirm.js +53 -0
  10. package/dist/board/components/DetailView.js +25 -0
  11. package/dist/board/components/EditProjectModal.js +106 -0
  12. package/dist/board/components/FilterBar.js +16 -0
  13. package/dist/board/components/Footer.js +52 -0
  14. package/dist/board/components/Header.js +46 -0
  15. package/dist/board/components/HelpModal.js +36 -0
  16. package/dist/board/components/Inspector.js +12 -0
  17. package/dist/board/components/LogModal.js +58 -0
  18. package/dist/board/components/Navigator.js +84 -0
  19. package/dist/board/components/ProjectsModal.js +70 -0
  20. package/dist/board/components/ProjectsPage.js +105 -0
  21. package/dist/board/components/RunHistory.js +83 -0
  22. package/dist/board/components/TaskBrowser.js +95 -0
  23. package/dist/board/components/TaskFilterBar.js +6 -0
  24. package/dist/board/components/TaskForm.js +177 -0
  25. package/dist/board/components/Timeline.js +5 -0
  26. package/dist/board/daemon.js +117 -0
  27. package/dist/board/format.js +102 -0
  28. package/dist/board/hooks/useBoardKeybindings.js +299 -0
  29. package/dist/board/hooks/useBreakpoint.js +6 -0
  30. package/dist/board/hooks/useHoverState.js +11 -0
  31. package/dist/board/hooks/useLogStream.js +32 -0
  32. package/dist/board/hooks/useLoopPolling.js +28 -0
  33. package/dist/board/hooks/useTaskKeybindings.js +114 -0
  34. package/dist/board/index.js +32 -0
  35. package/dist/board/router.js +16 -0
  36. package/dist/board/state.js +99 -0
  37. package/dist/board/toast.js +65 -0
  38. package/dist/board/types.js +1 -0
  39. package/dist/cli.js +134 -93
  40. package/dist/cli.js.map +1 -1
  41. package/dist/client/commands.d.ts +11 -0
  42. package/dist/client/commands.d.ts.map +1 -0
  43. package/dist/client/commands.js +317 -0
  44. package/dist/client/commands.js.map +1 -0
  45. package/dist/client/ipc.d.ts +5 -0
  46. package/dist/client/ipc.d.ts.map +1 -0
  47. package/dist/client/ipc.js +96 -0
  48. package/dist/client/ipc.js.map +1 -0
  49. package/dist/config/constants.js +29 -0
  50. package/dist/config/paths.js +49 -0
  51. package/dist/core/command-runner.js +82 -0
  52. package/dist/core/foreground-loop.js +81 -0
  53. package/dist/core/log-parser.js +29 -0
  54. package/dist/core/log-rotator.js +25 -0
  55. package/dist/core/loop-controller.js +429 -0
  56. package/dist/daemon/daemon-log.js +11 -0
  57. package/dist/daemon/index.d.ts +2 -0
  58. package/dist/daemon/index.d.ts.map +1 -0
  59. package/dist/daemon/index.js +43 -0
  60. package/dist/daemon/index.js.map +1 -0
  61. package/dist/daemon/manager.d.ts +17 -0
  62. package/dist/daemon/manager.d.ts.map +1 -0
  63. package/dist/daemon/manager.js +260 -0
  64. package/dist/daemon/manager.js.map +1 -0
  65. package/dist/daemon/projects.js +104 -0
  66. package/dist/daemon/server.d.ts +15 -0
  67. package/dist/daemon/server.d.ts.map +1 -0
  68. package/dist/daemon/server.js +345 -0
  69. package/dist/daemon/server.js.map +1 -0
  70. package/dist/daemon/spawner.d.ts +3 -0
  71. package/dist/daemon/spawner.d.ts.map +1 -0
  72. package/dist/daemon/spawner.js +82 -0
  73. package/dist/daemon/spawner.js.map +1 -0
  74. package/dist/daemon/state.d.ts +15 -0
  75. package/dist/daemon/state.d.ts.map +1 -0
  76. package/dist/daemon/state.js +151 -0
  77. package/dist/daemon/state.js.map +1 -0
  78. package/dist/daemon/task-manager.js +59 -0
  79. package/dist/duration.js +4 -4
  80. package/dist/entry.js +11 -0
  81. package/dist/esm-loader.js +36 -0
  82. package/dist/i18n/en.json +386 -0
  83. package/dist/i18n/index.js +11 -0
  84. package/dist/ipc/handlers/logs-stream.js +65 -0
  85. package/dist/ipc/send.js +5 -0
  86. package/dist/logger.js +0 -1
  87. package/dist/loop-config.d.ts +13 -0
  88. package/dist/loop-config.d.ts.map +1 -0
  89. package/dist/loop-config.js +83 -0
  90. package/dist/loop-config.js.map +1 -0
  91. package/dist/loop.d.ts +43 -2
  92. package/dist/loop.d.ts.map +1 -1
  93. package/dist/loop.js +247 -2
  94. package/dist/loop.js.map +1 -1
  95. package/dist/open-board.d.ts +2 -0
  96. package/dist/open-board.d.ts.map +1 -0
  97. package/dist/open-board.js +19 -0
  98. package/dist/open-board.js.map +1 -0
  99. package/dist/shared/clipboard.js +19 -0
  100. package/dist/shared/fs-utils.js +11 -0
  101. package/dist/shared/sleep.js +17 -0
  102. package/dist/shared/tail.js +4 -0
  103. package/dist/tui/app.d.ts +57 -0
  104. package/dist/tui/app.d.ts.map +1 -0
  105. package/dist/tui/app.js +167 -0
  106. package/dist/tui/app.js.map +1 -0
  107. package/dist/tui/create-loop.d.ts +3 -0
  108. package/dist/tui/create-loop.d.ts.map +1 -0
  109. package/dist/tui/create-loop.js +90 -0
  110. package/dist/tui/create-loop.js.map +1 -0
  111. package/dist/tui/dashboard.d.ts +2 -0
  112. package/dist/tui/dashboard.d.ts.map +1 -0
  113. package/dist/tui/dashboard.js +45 -0
  114. package/dist/tui/dashboard.js.map +1 -0
  115. package/dist/tui/events.d.ts +3 -0
  116. package/dist/tui/events.d.ts.map +1 -0
  117. package/dist/tui/events.js +244 -0
  118. package/dist/tui/events.js.map +1 -0
  119. package/dist/tui/popup.d.ts +3 -0
  120. package/dist/tui/popup.d.ts.map +1 -0
  121. package/dist/tui/popup.js +58 -0
  122. package/dist/tui/popup.js.map +1 -0
  123. package/dist/tui/render.d.ts +9 -0
  124. package/dist/tui/render.d.ts.map +1 -0
  125. package/dist/tui/render.js +305 -0
  126. package/dist/tui/render.js.map +1 -0
  127. package/dist/types.d.ts +74 -0
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/types.js +0 -1
  130. package/package.json +64 -55
@@ -0,0 +1,299 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { cycleSortMode, cycleStatusFilter } from "../state.js";
3
+ import { copyToClipboard } from "../../shared/clipboard.js";
4
+ import { getActionCount, getActionKeys } from "../components/ActionButtons.js";
5
+ const PANEL_ORDER = ["search", "project-filter", "status", "sort", "header-tasks", "header-projects", "header-new", "loops", "runs", "actions"];
6
+ const PANEL_LEFT = {
7
+ search: "actions",
8
+ "project-filter": "search",
9
+ status: "project-filter",
10
+ sort: "status",
11
+ "header-tasks": "sort",
12
+ "header-projects": "header-tasks",
13
+ "header-new": "header-projects",
14
+ loops: "header-new",
15
+ runs: "loops",
16
+ actions: "runs",
17
+ projects: "header-new",
18
+ };
19
+ const PANEL_RIGHT = {
20
+ search: "project-filter",
21
+ "project-filter": "status",
22
+ status: "sort",
23
+ sort: "header-tasks",
24
+ "header-tasks": "header-projects",
25
+ "header-projects": "header-new",
26
+ "header-new": "loops",
27
+ loops: "runs",
28
+ runs: "actions",
29
+ actions: "search",
30
+ projects: "loops",
31
+ };
32
+ function nextPanel(current, direction) {
33
+ return direction === "left" ? PANEL_LEFT[current] : PANEL_RIGHT[current];
34
+ }
35
+ const CONFIRM_KEYS = {
36
+ left: (p) => p.setConfirmChoice((c) => (c === 1 ? 0 : 1)),
37
+ right: (p) => p.setConfirmChoice((c) => (c === 1 ? 0 : 1)),
38
+ tab: (p) => p.setConfirmChoice((c) => (c === 1 ? 0 : 1)),
39
+ y: (p) => { const a = p.confirm.action; p.setConfirm(null); void a(); },
40
+ n: (p) => p.setConfirm(null),
41
+ escape: (p) => p.setConfirm(null),
42
+ return: (p) => {
43
+ if (p.confirmChoice === 1) {
44
+ const a = p.confirm.action;
45
+ p.setConfirm(null);
46
+ void a();
47
+ }
48
+ else
49
+ p.setConfirm(null);
50
+ },
51
+ enter: (p) => {
52
+ if (p.confirmChoice === 1) {
53
+ const a = p.confirm.action;
54
+ p.setConfirm(null);
55
+ void a();
56
+ }
57
+ else
58
+ p.setConfirm(null);
59
+ },
60
+ };
61
+ const VIEW_ESCAPE = {
62
+ create: (p) => { p.setEditTarget(null); p.onBack(); },
63
+ "task-create": (p) => { p.setEditTask(null); p.onBack(); },
64
+ "task-edit": (p) => { p.setEditTask(null); p.onBack(); },
65
+ "task-list": (p) => p.onBack(),
66
+ projects: (p) => p.onBack(),
67
+ };
68
+ const OVERLAY_DISMISS = {
69
+ log: (p) => { p.setLogModalRun(null); return true; },
70
+ help: (p) => { p.setHelpOpen(false); return true; },
71
+ search: (p, key) => {
72
+ p.setSearchActive(false);
73
+ p.setFocusedPanel(key === "return" || key === "enter" ? "loops" : "search");
74
+ return true;
75
+ },
76
+ };
77
+ const GLOBAL_KEYS = {
78
+ escape: (p) => { p.destroyLogSocket(); p.onQuit(); },
79
+ h: (p) => p.setHelpOpen(true),
80
+ n: (p) => { p.setEditTarget(null); p.push("create"); },
81
+ t: (p) => { p.setEditTask(null); p.push("task-create"); },
82
+ e: (p) => p.onAction("edit"),
83
+ d: (p) => p.onAction("delete"),
84
+ delete: (p) => p.onAction("delete"),
85
+ p: (p) => p.onAction("pause-or-play"),
86
+ s: (p) => p.onAction("stop"),
87
+ f: (p) => p.onAction("trigger"),
88
+ };
89
+ const panelHandlers = {
90
+ search: (key, p) => {
91
+ if (key === "return" || key === "enter") {
92
+ p.setSearchActive(true);
93
+ return true;
94
+ }
95
+ return false;
96
+ },
97
+ "project-filter": (key, p) => {
98
+ if (key === "return" || key === "enter") {
99
+ p.onSelectProject?.();
100
+ return true;
101
+ }
102
+ return false;
103
+ },
104
+ status: (key, p) => {
105
+ if (key === "return" || key === "enter") {
106
+ p.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) }));
107
+ return true;
108
+ }
109
+ return false;
110
+ },
111
+ sort: (key, p) => {
112
+ if (key === "return" || key === "enter") {
113
+ p.setSort((prev) => cycleSortMode(prev));
114
+ return true;
115
+ }
116
+ return false;
117
+ },
118
+ "header-tasks": (key, p) => {
119
+ if (key === "return" || key === "enter") {
120
+ p.onViewTasks?.();
121
+ return true;
122
+ }
123
+ return false;
124
+ },
125
+ "header-projects": (key, p) => {
126
+ if (key === "return" || key === "enter") {
127
+ p.onViewProjects?.();
128
+ return true;
129
+ }
130
+ return false;
131
+ },
132
+ "header-new": (key, p) => {
133
+ if (key === "return" || key === "enter") {
134
+ p.onAddLoop?.();
135
+ return true;
136
+ }
137
+ return false;
138
+ },
139
+ loops: (key, p) => {
140
+ if (key === "up" || key === "k") {
141
+ p.setSelectedIndex((i) => Math.max(0, i - 1));
142
+ return true;
143
+ }
144
+ if (key === "down" || key === "j") {
145
+ p.setSelectedIndex((i) => Math.min(p.selected ? p.selected.runHistory.length - 1 : 0, i + 1));
146
+ return true;
147
+ }
148
+ if (key === "return" || key === "enter") {
149
+ p.setFocusedPanel("actions");
150
+ return true;
151
+ }
152
+ return false;
153
+ },
154
+ runs: (key, p) => {
155
+ if (key === "up" || key === "k") {
156
+ p.setSelectedRunIndex((i) => Math.max(0, i - 1));
157
+ return true;
158
+ }
159
+ if (key === "down" || key === "j") {
160
+ p.setSelectedRunIndex((i) => Math.min(p.selectedRunCount - 1, i + 1));
161
+ return true;
162
+ }
163
+ if ((key === "return" || key === "enter") && p.selected && p.selected.runHistory.length > 0) {
164
+ const runs = p.selected.runHistory;
165
+ const idx = Math.min(p.selectedRunIndex, runs.length - 1);
166
+ p.onOpenRunLog(runs[runs.length - 1 - idx]);
167
+ return true;
168
+ }
169
+ return false;
170
+ },
171
+ actions: (key, p) => {
172
+ const count = p.selected ? getActionCount(p.selected.status) : 0;
173
+ const keys = p.selected ? getActionKeys(p.selected.status) : [];
174
+ if (key === "up" || key === "k") {
175
+ p.setSelectedAction((i) => Math.max(0, Math.min(i, count - 1)));
176
+ return true;
177
+ }
178
+ if (key === "down" || key === "j") {
179
+ p.setSelectedAction((i) => Math.min(count - 1, i + 1));
180
+ return true;
181
+ }
182
+ if (key === "return" || key === "enter") {
183
+ p.onAction(keys[p.selectedAction] ?? "edit");
184
+ return true;
185
+ }
186
+ return false;
187
+ },
188
+ projects: () => false,
189
+ };
190
+ const BOARD_SHORTCUTS = {
191
+ "/": (p) => p.setSearchActive(true),
192
+ o: (p) => p.setSort((prev) => cycleSortMode(prev)),
193
+ x: (p) => p.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })),
194
+ r: (p) => p.onSelectProject?.(),
195
+ c: (p) => p.onSelectProject?.(),
196
+ };
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;
199
+ useKeyboard((key) => {
200
+ const name = key.name;
201
+ if (confirm) {
202
+ CONFIRM_KEYS[name]?.({ confirm, confirmChoice, setConfirm, setConfirmChoice });
203
+ return;
204
+ }
205
+ if (logModalRun) {
206
+ if (name === "escape" || name === "q") {
207
+ OVERLAY_DISMISS.log({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
208
+ return;
209
+ }
210
+ if (key.ctrl && name === "c") {
211
+ copyToClipboard(logModalLines.join("\n"));
212
+ return;
213
+ }
214
+ return;
215
+ }
216
+ if (helpOpen && (name === "h" || name === "escape")) {
217
+ OVERLAY_DISMISS.help({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
218
+ return;
219
+ }
220
+ if (helpOpen)
221
+ return;
222
+ if (searchActive && (name === "escape" || name === "return" || name === "enter")) {
223
+ OVERLAY_DISMISS.search({ setLogModalRun, setHelpOpen, setSearchActive, setFocusedPanel }, name);
224
+ return;
225
+ }
226
+ if (searchActive) {
227
+ if (name === "left") {
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 }));
239
+ return;
240
+ }
241
+ return;
242
+ }
243
+ if (name === "left" || name === "right") {
244
+ if (focusedPanel === "actions") {
245
+ const actionCount = selected ? getActionCount(selected.status) : 0;
246
+ if (name === "left" && selectedAction === 0) {
247
+ setFocusedPanel((p) => nextPanel(p, "left"));
248
+ }
249
+ else if (name === "right" && selectedAction === actionCount - 1) {
250
+ setFocusedPanel((p) => nextPanel(p, "right"));
251
+ }
252
+ else {
253
+ setSelectedAction((i) => name === "right"
254
+ ? Math.min(actionCount - 1, i + 1)
255
+ : Math.max(0, i - 1));
256
+ }
257
+ }
258
+ else {
259
+ const next = nextPanel(focusedPanel, name === "right" ? "right" : "left");
260
+ if (next === "actions") {
261
+ setFocusedPanel("actions");
262
+ setSelectedAction(0);
263
+ }
264
+ else {
265
+ setFocusedPanel(next);
266
+ }
267
+ }
268
+ return;
269
+ }
270
+ if (view !== "board" && name === "escape") {
271
+ VIEW_ESCAPE[view]?.({ setEditTarget, setEditTask, onBack: pop });
272
+ return;
273
+ }
274
+ if (view !== "board")
275
+ return;
276
+ const globalHandler = GLOBAL_KEYS[name];
277
+ if (globalHandler) {
278
+ globalHandler({ destroyLogSocket, onQuit, setHelpOpen, setEditTarget, setEditTask, push, onAction });
279
+ return;
280
+ }
281
+ const panelHandler = panelHandlers[focusedPanel];
282
+ if (panelHandler?.(name, {
283
+ setSearchActive, setFilters, setSort, setEditTarget, push,
284
+ setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
285
+ selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
286
+ refreshTasks, onViewTasks, onViewProjects, onAddLoop,
287
+ }))
288
+ return;
289
+ const shortcut = BOARD_SHORTCUTS[name];
290
+ if (shortcut) {
291
+ shortcut({
292
+ setSearchActive, setFilters, setSort, setEditTarget, push,
293
+ setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
294
+ selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
295
+ onSelectProject,
296
+ });
297
+ }
298
+ });
299
+ }
@@ -0,0 +1,6 @@
1
+ import { useTerminalDimensions } from "@opentui/react";
2
+ import { BOARD_BREAKPOINT_WIDTH } from "../../config/constants.js";
3
+ export function useBreakpoint() {
4
+ const { width } = useTerminalDimensions();
5
+ return width < BOARD_BREAKPOINT_WIDTH ? "narrow" : "wide";
6
+ }
@@ -0,0 +1,11 @@
1
+ import { useState } from "react";
2
+ export function useHoverState() {
3
+ const [isHovered, setIsHovered] = useState(false);
4
+ return {
5
+ isHovered,
6
+ hoverProps: {
7
+ onMouseOver: () => setIsHovered(true),
8
+ onMouseOut: () => setIsHovered(false),
9
+ },
10
+ };
11
+ }
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { streamLogs } from "../daemon.js";
3
+ import { t } from "../../i18n/index.js";
4
+ import { LOG_LINES_MAX } from "../../config/constants.js";
5
+ export function useLogStream(selectedId, view, onError) {
6
+ const [logLines, setLogLines] = useState([]);
7
+ const logSocket = useRef(null);
8
+ useEffect(() => {
9
+ logSocket.current?.destroy();
10
+ logSocket.current = null;
11
+ setLogLines([]);
12
+ if (!selectedId || view !== "board") {
13
+ return;
14
+ }
15
+ setLogLines([t("board.logWaiting")]);
16
+ const socket = streamLogs(selectedId, (line) => setLogLines((prev) => {
17
+ const next = prev[0] === t("board.logWaiting") ? [] : prev;
18
+ return [...next, line].slice(-LOG_LINES_MAX);
19
+ }), onError);
20
+ logSocket.current = socket;
21
+ return () => {
22
+ socket.destroy();
23
+ if (logSocket.current === socket) {
24
+ logSocket.current = null;
25
+ }
26
+ };
27
+ }, [selectedId, view]);
28
+ return {
29
+ logLines,
30
+ destroy: () => logSocket.current?.destroy(),
31
+ };
32
+ }
@@ -0,0 +1,28 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { listLoops } from "../daemon.js";
3
+ import { POLL_MS } from "../../config/constants.js";
4
+ export function useLoopPolling() {
5
+ const [loops, setLoops] = useState([]);
6
+ const [daemonStatus, setDaemonStatus] = useState("starting");
7
+ const lastSerialized = useRef("");
8
+ const refresh = useCallback(async () => {
9
+ try {
10
+ const next = await listLoops();
11
+ const serialized = JSON.stringify(next);
12
+ if (serialized !== lastSerialized.current) {
13
+ lastSerialized.current = serialized;
14
+ setLoops(next);
15
+ }
16
+ setDaemonStatus("connected");
17
+ }
18
+ catch {
19
+ setDaemonStatus("error");
20
+ }
21
+ }, []);
22
+ useEffect(() => {
23
+ void refresh();
24
+ const timer = setInterval(() => void refresh(), POLL_MS);
25
+ return () => clearInterval(timer);
26
+ }, [refresh]);
27
+ return { loops, daemonStatus, refresh };
28
+ }
@@ -0,0 +1,114 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { nextTaskPanel } from "../components/TaskBrowser.js";
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;
5
+ const taskActions = selectable
6
+ ? ["select", "edit", "delete"]
7
+ : ["edit", "delete"];
8
+ const taskActionCount = taskActions.length;
9
+ useKeyboard((key) => {
10
+ if (view !== "task-list")
11
+ return;
12
+ const name = key.name;
13
+ if (confirm)
14
+ return;
15
+ if (taskSearchActive) {
16
+ if (name === "escape") {
17
+ setTaskSearchActive(false);
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);
38
+ return;
39
+ }
40
+ return;
41
+ }
42
+ if (name === "escape") {
43
+ onCancel();
44
+ return;
45
+ }
46
+ if (name === "n") {
47
+ onCreateTask();
48
+ return;
49
+ }
50
+ if (name === "/" && taskFocusedPanel === "tasks") {
51
+ setTaskSearchActive(true);
52
+ return;
53
+ }
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) {
61
+ setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
62
+ }
63
+ else if (name === "right" && taskSelectedAction === taskActionCount - 1) {
64
+ setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
65
+ }
66
+ else {
67
+ setTaskSelectedAction((i) => name === "right"
68
+ ? Math.min(taskActionCount - 1, i + 1)
69
+ : Math.max(0, i - 1));
70
+ }
71
+ }
72
+ else {
73
+ setTaskFocusedPanel((p) => nextTaskPanel(p, name === "right" ? "right" : "left"));
74
+ }
75
+ return;
76
+ }
77
+ if (taskFocusedPanel === "tasks") {
78
+ if (name === "up" || name === "k") {
79
+ setTaskSelectedIndex((i) => Math.max(0, i - 1));
80
+ return;
81
+ }
82
+ if (name === "down" || name === "j") {
83
+ setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
84
+ return;
85
+ }
86
+ if (name === "return" || name === "enter") {
87
+ setTaskFocusedPanel("actions");
88
+ return;
89
+ }
90
+ if (name === "e" && tasks[taskSelectedIndex]) {
91
+ onTaskAction("edit");
92
+ return;
93
+ }
94
+ if (name === "delete" && tasks[taskSelectedIndex]) {
95
+ onTaskAction("delete");
96
+ return;
97
+ }
98
+ }
99
+ if (taskFocusedPanel === "actions") {
100
+ if (name === "up" || name === "k") {
101
+ setTaskSelectedAction((i) => Math.max(0, i - 1));
102
+ return;
103
+ }
104
+ if (name === "down" || name === "j") {
105
+ setTaskSelectedAction((i) => Math.min(taskActionCount - 1, i + 1));
106
+ return;
107
+ }
108
+ if (name === "return" || name === "enter") {
109
+ onTaskAction(taskActions[taskSelectedAction] ?? "select");
110
+ return;
111
+ }
112
+ }
113
+ });
114
+ }
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { createCliRenderer } from "@opentui/core";
3
+ import { createRoot } from "@opentui/react";
4
+ import { App } from "./App.js";
5
+ import { boardLog } from "./board-log.js";
6
+ export async function launchBoard() {
7
+ boardLog("board startup");
8
+ process.on("uncaughtException", (error) => {
9
+ boardLog("uncaught exception", error);
10
+ });
11
+ process.on("unhandledRejection", (reason) => {
12
+ boardLog("unhandled rejection", reason);
13
+ });
14
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
15
+ const quit = () => {
16
+ try {
17
+ renderer.destroy();
18
+ }
19
+ catch {
20
+ // ignore teardown errors
21
+ }
22
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
23
+ process.exit(0);
24
+ };
25
+ try {
26
+ createRoot(renderer).render(_jsx(App, { onQuit: quit }));
27
+ }
28
+ catch (error) {
29
+ boardLog("render failure", error);
30
+ throw error;
31
+ }
32
+ }
@@ -0,0 +1,16 @@
1
+ import { useState, useCallback } from "react";
2
+ export function useRouter(initial = "board") {
3
+ const [stack, setStack] = useState([initial]);
4
+ const view = stack[stack.length - 1] ?? initial;
5
+ const canGoBack = stack.length > 1;
6
+ const push = useCallback((next) => {
7
+ setStack((s) => [...s, next]);
8
+ }, []);
9
+ const replace = useCallback((next) => {
10
+ setStack((s) => [...s.slice(0, -1), next]);
11
+ }, []);
12
+ const pop = useCallback(() => {
13
+ setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
14
+ }, []);
15
+ return { view, stack, push, replace, pop, canGoBack };
16
+ }
@@ -0,0 +1,99 @@
1
+ export const defaultFilters = {
2
+ status: "all",
3
+ intervalBucket: "all",
4
+ recentActivity: "all",
5
+ query: "",
6
+ };
7
+ const statusOrder = {
8
+ running: 0,
9
+ waiting: 1,
10
+ paused: 2,
11
+ idle: 3,
12
+ stopped: 4,
13
+ };
14
+ function intervalBucketOf(interval) {
15
+ if (interval <= 60_000)
16
+ return "short";
17
+ if (interval <= 3_600_000)
18
+ return "medium";
19
+ return "long";
20
+ }
21
+ function activityBucketOf(loop) {
22
+ const baseline = loop.lastRunAt ?? loop.createdAt;
23
+ const ageMs = Date.now() - new Date(baseline).getTime();
24
+ return ageMs <= 15 * 60_000 ? "active" : "stale";
25
+ }
26
+ function matches(loop, filters) {
27
+ if (filters.status !== "all" && loop.status !== filters.status) {
28
+ return false;
29
+ }
30
+ if (filters.intervalBucket !== "all" &&
31
+ intervalBucketOf(loop.interval) !== filters.intervalBucket) {
32
+ return false;
33
+ }
34
+ if (filters.recentActivity !== "all" &&
35
+ activityBucketOf(loop) !== filters.recentActivity) {
36
+ return false;
37
+ }
38
+ const query = filters.query.trim().toLowerCase();
39
+ if (!query) {
40
+ return true;
41
+ }
42
+ const haystack = [
43
+ loop.id,
44
+ loop.command,
45
+ ...loop.commandArgs,
46
+ loop.description ?? "",
47
+ loop.status,
48
+ loop.intervalHuman,
49
+ ]
50
+ .join(" ")
51
+ .toLowerCase();
52
+ return haystack.includes(query);
53
+ }
54
+ function describeLoop(loop) {
55
+ return loop.description?.trim() || [loop.command, ...loop.commandArgs].join(" ").trim();
56
+ }
57
+ function compare(left, right, sort) {
58
+ if (sort === "description") {
59
+ return describeLoop(left).localeCompare(describeLoop(right));
60
+ }
61
+ if (sort === "created") {
62
+ return right.createdAt.localeCompare(left.createdAt);
63
+ }
64
+ if (sort === "recent") {
65
+ return (right.lastRunAt ?? right.createdAt).localeCompare(left.lastRunAt ?? left.createdAt);
66
+ }
67
+ const byStatus = statusOrder[left.status] - statusOrder[right.status];
68
+ if (byStatus !== 0) {
69
+ return byStatus;
70
+ }
71
+ const leftNext = left.nextRunAt ?? left.lastRunAt ?? left.createdAt;
72
+ const rightNext = right.nextRunAt ?? right.lastRunAt ?? right.createdAt;
73
+ return rightNext.localeCompare(leftNext);
74
+ }
75
+ export function applyLoopFilters(loops, filters, sort) {
76
+ return loops
77
+ .filter((loop) => matches(loop, filters))
78
+ .sort((left, right) => compare(left, right, sort));
79
+ }
80
+ const SORT_CYCLE = {
81
+ description: "status",
82
+ status: "recent",
83
+ recent: "created",
84
+ created: "description",
85
+ };
86
+ export function cycleSortMode(mode) {
87
+ return SORT_CYCLE[mode];
88
+ }
89
+ const STATUS_CYCLE = {
90
+ all: "running",
91
+ running: "waiting",
92
+ waiting: "paused",
93
+ paused: "idle",
94
+ idle: "stopped",
95
+ stopped: "all",
96
+ };
97
+ export function cycleStatusFilter(status) {
98
+ return STATUS_CYCLE[status];
99
+ }