loop-task 2.0.2 → 2.0.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.
Files changed (131) hide show
  1. package/dist/config/constants.js +10 -0
  2. package/dist/i18n/en.json +12 -0
  3. package/dist/loop-config.js +59 -0
  4. package/dist/shared/useUndoRedo.js +79 -0
  5. package/dist/tui/components/CodeEditorModal.js +245 -0
  6. package/dist/tui/components/CodeEditorPreview.js +14 -0
  7. package/dist/tui/components/CreateForm.js +12 -10
  8. package/dist/tui/components/TaskForm.js +12 -12
  9. package/dist/tui/utils/syntax.js +128 -0
  10. package/package.json +2 -2
  11. package/dist/board/App.js +0 -405
  12. package/dist/board/board-log.js +0 -18
  13. package/dist/board/components/ActionButtons.js +0 -46
  14. package/dist/board/components/BoardButton.js +0 -58
  15. package/dist/board/components/ConfirmModal.js +0 -36
  16. package/dist/board/components/ContextHelpModal.js +0 -22
  17. package/dist/board/components/CreateForm.js +0 -271
  18. package/dist/board/components/CreateProjectModal.js +0 -97
  19. package/dist/board/components/DeleteProjectConfirm.js +0 -53
  20. package/dist/board/components/DetailView.js +0 -25
  21. package/dist/board/components/EditProjectModal.js +0 -99
  22. package/dist/board/components/FilterBar.js +0 -17
  23. package/dist/board/components/Footer.js +0 -52
  24. package/dist/board/components/Header.js +0 -46
  25. package/dist/board/components/HelpModal.js +0 -65
  26. package/dist/board/components/Inspector.js +0 -12
  27. package/dist/board/components/LogModal.js +0 -82
  28. package/dist/board/components/Navigator.js +0 -84
  29. package/dist/board/components/ProjectsModal.js +0 -70
  30. package/dist/board/components/ProjectsPage.js +0 -134
  31. package/dist/board/components/RunHistory.js +0 -83
  32. package/dist/board/components/SearchBox.js +0 -10
  33. package/dist/board/components/SearchSelect.js +0 -132
  34. package/dist/board/components/TaskBrowser.js +0 -95
  35. package/dist/board/components/TaskFilterBar.js +0 -6
  36. package/dist/board/components/TaskForm.js +0 -155
  37. package/dist/board/components/Timeline.js +0 -5
  38. package/dist/board/daemon.js +0 -117
  39. package/dist/board/focus-context.js +0 -98
  40. package/dist/board/hooks/useBoardKeybindings.js +0 -344
  41. package/dist/board/hooks/useBreakpoint.js +0 -6
  42. package/dist/board/hooks/useHoverState.js +0 -11
  43. package/dist/board/hooks/useInputShortcuts.js +0 -61
  44. package/dist/board/hooks/useLogStream.js +0 -32
  45. package/dist/board/hooks/useLoopPolling.js +0 -28
  46. package/dist/board/hooks/useTabNav.js +0 -60
  47. package/dist/board/hooks/useTaskKeybindings.js +0 -120
  48. package/dist/board/index.js +0 -32
  49. package/dist/board/router.js +0 -16
  50. package/dist/board/state.js +0 -99
  51. package/dist/board/toast.js +0 -65
  52. package/dist/board/types.js +0 -1
  53. package/dist/cli.d.ts +0 -3
  54. package/dist/cli.d.ts.map +0 -1
  55. package/dist/cli.js.map +0 -1
  56. package/dist/client/commands.d.ts +0 -11
  57. package/dist/client/commands.d.ts.map +0 -1
  58. package/dist/client/commands.js.map +0 -1
  59. package/dist/client/ipc.d.ts +0 -5
  60. package/dist/client/ipc.d.ts.map +0 -1
  61. package/dist/client/ipc.js.map +0 -1
  62. package/dist/daemon/index.d.ts +0 -2
  63. package/dist/daemon/index.d.ts.map +0 -1
  64. package/dist/daemon/index.js.map +0 -1
  65. package/dist/daemon/manager.d.ts +0 -17
  66. package/dist/daemon/manager.d.ts.map +0 -1
  67. package/dist/daemon/manager.js.map +0 -1
  68. package/dist/daemon/server.d.ts +0 -15
  69. package/dist/daemon/server.d.ts.map +0 -1
  70. package/dist/daemon/server.js.map +0 -1
  71. package/dist/daemon/spawner.d.ts +0 -3
  72. package/dist/daemon/spawner.d.ts.map +0 -1
  73. package/dist/daemon/spawner.js.map +0 -1
  74. package/dist/daemon/state.d.ts +0 -15
  75. package/dist/daemon/state.d.ts.map +0 -1
  76. package/dist/daemon/state.js.map +0 -1
  77. package/dist/duration.d.ts +0 -3
  78. package/dist/duration.d.ts.map +0 -1
  79. package/dist/duration.js.map +0 -1
  80. package/dist/logger.d.ts +0 -11
  81. package/dist/logger.d.ts.map +0 -1
  82. package/dist/logger.js.map +0 -1
  83. package/dist/loop-config.d.ts +0 -13
  84. package/dist/loop-config.d.ts.map +0 -1
  85. package/dist/loop-config.js.map +0 -1
  86. package/dist/loop.d.ts +0 -46
  87. package/dist/loop.d.ts.map +0 -1
  88. package/dist/loop.js +0 -363
  89. package/dist/loop.js.map +0 -1
  90. package/dist/open-board.d.ts +0 -2
  91. package/dist/open-board.d.ts.map +0 -1
  92. package/dist/open-board.js +0 -19
  93. package/dist/open-board.js.map +0 -1
  94. package/dist/tui/app.d.ts +0 -57
  95. package/dist/tui/app.d.ts.map +0 -1
  96. package/dist/tui/app.js.map +0 -1
  97. package/dist/tui/components/ActionButtons.js +0 -49
  98. package/dist/tui/components/CommandBuilderField.js +0 -143
  99. package/dist/tui/components/CommandEditorModal.js +0 -159
  100. package/dist/tui/components/ConfirmModal.js +0 -9
  101. package/dist/tui/components/FilterBar.js +0 -13
  102. package/dist/tui/components/FocusableSearchSelect.js +0 -82
  103. package/dist/tui/components/Footer.js +0 -42
  104. package/dist/tui/components/InlineCommandEditor.js +0 -143
  105. package/dist/tui/components/PatchEditForm.js +0 -78
  106. package/dist/tui/components/SearchSelect.js +0 -67
  107. package/dist/tui/components/TaskFilterBar.js +0 -9
  108. package/dist/tui/create-loop.d.ts +0 -3
  109. package/dist/tui/create-loop.d.ts.map +0 -1
  110. package/dist/tui/create-loop.js +0 -90
  111. package/dist/tui/create-loop.js.map +0 -1
  112. package/dist/tui/dashboard.d.ts +0 -2
  113. package/dist/tui/dashboard.d.ts.map +0 -1
  114. package/dist/tui/dashboard.js +0 -45
  115. package/dist/tui/dashboard.js.map +0 -1
  116. package/dist/tui/events.d.ts +0 -3
  117. package/dist/tui/events.d.ts.map +0 -1
  118. package/dist/tui/events.js +0 -244
  119. package/dist/tui/events.js.map +0 -1
  120. package/dist/tui/popup.d.ts +0 -3
  121. package/dist/tui/popup.d.ts.map +0 -1
  122. package/dist/tui/popup.js +0 -58
  123. package/dist/tui/popup.js.map +0 -1
  124. package/dist/tui/render.d.ts +0 -9
  125. package/dist/tui/render.d.ts.map +0 -1
  126. package/dist/tui/render.js +0 -305
  127. package/dist/tui/render.js.map +0 -1
  128. package/dist/types.d.ts +0 -94
  129. package/dist/types.d.ts.map +0 -1
  130. package/dist/types.js.map +0 -1
  131. /package/dist/tui/{app.js → App.js} +0 -0
@@ -0,0 +1,128 @@
1
+ const OPERATORS = new Set(["|", "&&", "||", ";", ">", ">>", "<"]);
2
+ /**
3
+ * Scan a command line into tokens, preserving whitespace runs as
4
+ * "whitespace" tokens. Used by highlightSegments for rendering.
5
+ */
6
+ function scanTokens(line) {
7
+ if (!line)
8
+ return [];
9
+ const tokens = [];
10
+ let i = 0;
11
+ const len = line.length;
12
+ while (i < len) {
13
+ // Whitespace run — preserve it as a token
14
+ if (line[i] === " " || line[i] === "\t") {
15
+ let j = i;
16
+ while (j < len && (line[j] === " " || line[j] === "\t"))
17
+ j++;
18
+ tokens.push({ type: "whitespace", value: line.slice(i, j) });
19
+ i = j;
20
+ continue;
21
+ }
22
+ // Try to match multi-char operators first (&&, ||, >>)
23
+ let opMatch;
24
+ for (const op of OPERATORS) {
25
+ if (op.length > 1 && line.slice(i, i + op.length) === op) {
26
+ opMatch = op;
27
+ break;
28
+ }
29
+ }
30
+ if (opMatch) {
31
+ tokens.push({ type: "operator", value: opMatch });
32
+ i += opMatch.length;
33
+ continue;
34
+ }
35
+ // Try to match single-char operators
36
+ if (OPERATORS.has(line[i])) {
37
+ tokens.push({ type: "operator", value: line[i] });
38
+ i++;
39
+ continue;
40
+ }
41
+ // Quoted string
42
+ if (line[i] === '"' || line[i] === "'") {
43
+ const quote = line[i];
44
+ let j = i + 1;
45
+ while (j < len && line[j] !== quote) {
46
+ // Handle escaped quote
47
+ if (line[j] === "\\" && j + 1 < len) {
48
+ j += 2;
49
+ }
50
+ else {
51
+ j++;
52
+ }
53
+ }
54
+ // Include closing quote if found
55
+ if (j < len && line[j] === quote)
56
+ j++;
57
+ tokens.push({ type: "string", value: line.slice(i, j) });
58
+ i = j;
59
+ continue;
60
+ }
61
+ // Unquoted word — consume until whitespace or operator
62
+ let j = i;
63
+ while (j < len) {
64
+ if (line[j] === " " || line[j] === "\t")
65
+ break;
66
+ // Check for multi-char operator boundary
67
+ let isOpBoundary = false;
68
+ for (const op of OPERATORS) {
69
+ if (op.length > 1 && line.slice(j, j + op.length) === op) {
70
+ isOpBoundary = true;
71
+ break;
72
+ }
73
+ }
74
+ if (isOpBoundary)
75
+ break;
76
+ // Check for single-char operator boundary
77
+ if (OPERATORS.has(line[j])) {
78
+ break;
79
+ }
80
+ // Check for quoted string start
81
+ if (line[j] === '"' || line[j] === "'")
82
+ break;
83
+ j++;
84
+ }
85
+ const raw = line.slice(i, j);
86
+ tokens.push(classifyWord(raw));
87
+ i = j;
88
+ }
89
+ return tokens;
90
+ }
91
+ /**
92
+ * Tokenize a command line for syntax highlighting purposes.
93
+ * This is a cosmetic tokenizer — the real parsing for execution
94
+ * lives in src/loop-config.ts parseCommandLine.
95
+ *
96
+ * Whitespace is not returned (use highlightSegments for rendering
97
+ * that needs to preserve spacing).
98
+ */
99
+ export function tokenizeCommand(line) {
100
+ return scanTokens(line).filter((tok) => tok.type !== "whitespace");
101
+ }
102
+ /**
103
+ * Produce colored segments for a command line, preserving whitespace
104
+ * exactly so token-by-token rendering does not collapse spaces.
105
+ */
106
+ export function highlightSegments(line, colors, whitespaceColor) {
107
+ return scanTokens(line).map((tok) => ({
108
+ value: tok.value,
109
+ color: tok.type === "whitespace" ? whitespaceColor : colors[tok.type],
110
+ }));
111
+ }
112
+ function classifyWord(value) {
113
+ // Flag: --something (long flag)
114
+ if (value.startsWith("--") && value.length > 2) {
115
+ return { type: "flag", value };
116
+ }
117
+ // Flag: -f (single letter flag, not negative number like -1)
118
+ if (value.startsWith("-") &&
119
+ value.length === 2 &&
120
+ /[a-zA-Z]/.test(value[1])) {
121
+ return { type: "flag", value };
122
+ }
123
+ // Flag: -abc (combined short flags like -xyz)
124
+ if (value.startsWith("-") && value.length > 1 && /^[a-zA-Z]+$/.test(value.slice(1))) {
125
+ return { type: "flag", value };
126
+ }
127
+ return { type: "word", value };
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.2",
3
+ "version": "2.0.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": {
@@ -72,7 +72,7 @@
72
72
  "vitest": "^3.1.0"
73
73
  },
74
74
  "scripts": {
75
- "build": "tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
75
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
76
76
  "start": "node dist/entry.js",
77
77
  "dev": "tsx src/cli.ts",
78
78
  "dev:watch": "tsx --watch src/cli.ts",
package/dist/board/App.js DELETED
@@ -1,405 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
3
- import { applyLoopFilters, cycleSortMode, cycleStatusFilter, defaultFilters, } from "./state.js";
4
- import { ToastStack, useToasts } from "./toast.js";
5
- import { t } from "../i18n/index.js";
6
- import { useLoopPolling } from "./hooks/useLoopPolling.js";
7
- import { useLogStream } from "./hooks/useLogStream.js";
8
- import { useBoardKeybindings } from "./hooks/useBoardKeybindings.js";
9
- import { useTaskKeybindings } from "./hooks/useTaskKeybindings.js";
10
- import { Header } from "./components/Header.js";
11
- import { FilterBar } from "./components/FilterBar.js";
12
- import { Navigator } from "./components/Navigator.js";
13
- import { Inspector } from "./components/Inspector.js";
14
- import { RunHistory } from "./components/RunHistory.js";
15
- import { ActionButtons, getActionKeys } from "./components/ActionButtons.js";
16
- import { HelpModal } from "./components/HelpModal.js";
17
- import { ContextHelpModal } from "./components/ContextHelpModal.js";
18
- import { Footer } from "./components/Footer.js";
19
- import { ConfirmModal } from "./components/ConfirmModal.js";
20
- import { CreateView, createInitialValues } from "./components/CreateForm.js";
21
- import { TaskForm } from "./components/TaskForm.js";
22
- import { TaskNavigator, TaskInspector, TaskActionButtons } from "./components/TaskBrowser.js";
23
- import { TaskFilterBar } from "./components/TaskFilterBar.js";
24
- import { LogModal } from "./components/LogModal.js";
25
- import { ProjectsModal } from "./components/ProjectsModal.js";
26
- import { ProjectsPage } from "./components/ProjectsPage.js";
27
- import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
28
- import { useBreakpoint } from "./hooks/useBreakpoint.js";
29
- import { useRouter } from "./router.js";
30
- import { POLL_MS } from "../config/constants.js";
31
- import { useTabNav } from "./hooks/useTabNav.js";
32
- const VIEW_TO_MODE = {
33
- create: "create",
34
- "task-create": "task",
35
- "task-edit": "task",
36
- "task-list": "task",
37
- projects: "projects",
38
- };
39
- function resolveMode(confirm, searchActive, helpOpen, view) {
40
- if (confirm)
41
- return "confirm";
42
- if (searchActive)
43
- return "search";
44
- if (helpOpen)
45
- return "help";
46
- return VIEW_TO_MODE[view] ?? "normal";
47
- }
48
- function viewKey(view, editTarget, editTask) {
49
- if (view === "create")
50
- return `${view}:${editTarget?.id ?? "new"}`;
51
- if (view === "task-edit")
52
- return `${view}:${editTask?.id ?? "new"}`;
53
- return view;
54
- }
55
- export function App(props) {
56
- const { loops, daemonStatus, refresh } = useLoopPolling();
57
- const { view, stack, push, replace, pop } = useRouter("board");
58
- const [filters, setFilters] = useState(defaultFilters);
59
- const [sort, setSort] = useState("description");
60
- const [selectedIndex, setSelectedIndex] = useState(0);
61
- const [searchActive, setSearchActive] = useState(false);
62
- const [helpOpen, setHelpOpen] = useState(false);
63
- const [contextHelpOpen, setContextHelpOpen] = useState(false);
64
- const [confirm, setConfirm] = useState(null);
65
- const [confirmChoice, setConfirmChoice] = useState(0);
66
- const [editTarget, setEditTarget] = useState(null);
67
- const [cloneMode, setCloneMode] = useState(false);
68
- const [editTask, setEditTask] = useState(null);
69
- const [pendingTaskSelection, setPendingTaskSelection] = useState(null);
70
- const [selectedRunIndex, setSelectedRunIndex] = useState(0);
71
- const [selectedAction, setSelectedAction] = useState(0);
72
- const [logModalRun, setLogModalRun] = useState(null);
73
- const [logModalLines, setLogModalLines] = useState([]);
74
- const [logModalLoading, setLogModalLoading] = useState(false);
75
- const [tasks, setTasks] = useState([]);
76
- const [taskSelectedIndex, setTaskSelectedIndex] = useState(0);
77
- const [taskSelectedAction, setTaskSelectedAction] = useState(0);
78
- const [taskSearchActive, setTaskSearchActive] = useState(false);
79
- const [taskQuery, setTaskQuery] = useState("");
80
- const [projects, setProjects] = useState([]);
81
- const createProjectTriggerRef = useRef(null);
82
- const [currentProjectId, setCurrentProjectId] = useState(() => {
83
- try {
84
- return localStorage.getItem("loop-current-project") ?? "all";
85
- }
86
- catch {
87
- return "all";
88
- }
89
- });
90
- const [projectsModalOpen, setProjectsModalOpen] = useState(false);
91
- const { toasts, push: pushToast } = useToasts();
92
- const breakpoint = useBreakpoint();
93
- const visible = useMemo(() => applyLoopFilters(currentProjectId === "all" ? loops : loops.filter((l) => (l.projectId ?? "default") === currentProjectId), filters, sort), [loops, filters, sort, currentProjectId]);
94
- const clampedIndex = Math.min(selectedIndex, Math.max(0, visible.length - 1));
95
- const selected = visible[clampedIndex] ?? null;
96
- const selectedId = selected?.id ?? null;
97
- const selectedDesc = selected?.description ?? "";
98
- const prevSelectedId = useRef(null);
99
- if (selectedId !== prevSelectedId.current) {
100
- prevSelectedId.current = selectedId;
101
- setSelectedRunIndex(0);
102
- }
103
- useEffect(() => {
104
- setSelectedAction(0);
105
- }, [selected?.id]);
106
- const { destroy: destroyLogSocket } = useLogStream(selectedId, view, (error) => pushToast("error", t("board.logStreamError", { message: error.message })));
107
- const filteredTasks = useMemo(() => {
108
- if (!taskQuery)
109
- return tasks;
110
- const q = taskQuery.toLowerCase();
111
- return tasks.filter((t) => `${t.id} ${t.name} ${t.command}`.toLowerCase().includes(q));
112
- }, [tasks, taskQuery]);
113
- const taskClampedIndex = Math.min(taskSelectedIndex, Math.max(0, filteredTasks.length - 1));
114
- const selectedTask = filteredTasks[taskClampedIndex] ?? null;
115
- async function refreshTasks() {
116
- try {
117
- setTasks(await listTasks());
118
- }
119
- catch { /* ignore */ }
120
- }
121
- async function refreshProjects() {
122
- try {
123
- setProjects(await listProjects());
124
- }
125
- catch { /* ignore */ }
126
- }
127
- useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
128
- useEffect(() => {
129
- const timer = setInterval(() => {
130
- void refreshTasks();
131
- void refreshProjects();
132
- }, POLL_MS);
133
- return () => clearInterval(timer);
134
- }, []);
135
- useEffect(() => {
136
- try {
137
- localStorage.setItem("loop-current-project", currentProjectId);
138
- }
139
- catch { }
140
- }, [currentProjectId]);
141
- function runAction(label, action) {
142
- return async () => {
143
- try {
144
- await action();
145
- void refresh();
146
- pushToast("success", label);
147
- }
148
- catch (error) {
149
- pushToast("error", error instanceof Error ? error.message : String(error));
150
- }
151
- };
152
- }
153
- function handleOpenRunLog(run) {
154
- if (!selectedId)
155
- return;
156
- setLogModalRun(run);
157
- if (run.status === "running") {
158
- setLogModalLoading(false);
159
- setLogModalLines([]);
160
- return;
161
- }
162
- setLogModalLoading(true);
163
- setLogModalLines([]);
164
- fetchRunLog(selectedId, run.runNumber)
165
- .then((log) => {
166
- setLogModalLines(log ? log.split("\n") : []);
167
- setLogModalLoading(false);
168
- })
169
- .catch(() => {
170
- setLogModalLines([]);
171
- setLogModalLoading(false);
172
- });
173
- }
174
- function confirmAction(msg, toast, fn) {
175
- setConfirmChoice(0);
176
- setConfirm({ message: msg, action: runAction(toast, fn) });
177
- }
178
- function handleAction(action) {
179
- if (!selected || !selectedId)
180
- return;
181
- if (action === "edit") {
182
- setEditTarget(selected);
183
- push("create");
184
- return;
185
- }
186
- if (action === "clone") {
187
- const copy = {
188
- ...selected,
189
- id: "",
190
- description: `${selected.description} - Copy`,
191
- };
192
- setEditTarget(copy);
193
- setCloneMode(true);
194
- push("create");
195
- return;
196
- }
197
- if (action === "delete") {
198
- confirmAction(t("board.confirmDelete", { desc: selectedDesc }), t("board.toastDeleted", { desc: selectedDesc }), () => deleteLoop(selectedId));
199
- return;
200
- }
201
- if (action === "pause-or-play") {
202
- if (selected.status === "waiting") {
203
- confirmAction(t("board.confirmPause", { desc: selectedDesc }), t("board.toastPaused", { desc: selectedDesc }), () => pauseLoop(selectedId));
204
- }
205
- else if (selected.status === "paused") {
206
- confirmAction(t("board.confirmResume", { desc: selectedDesc }), t("board.toastResumed", { desc: selectedDesc }), () => resumeLoop(selectedId));
207
- }
208
- else {
209
- confirmAction(t("board.confirmPlay", { desc: selectedDesc }), t("board.toastPlayed", { desc: selectedDesc }), () => playLoop(selectedId));
210
- }
211
- return;
212
- }
213
- if (action === "stop") {
214
- confirmAction(t("board.confirmStop", { desc: selectedDesc }), t("board.toastStopped", { desc: selectedDesc }), () => stopLoop(selectedId));
215
- return;
216
- }
217
- if (action === "play") {
218
- const isPaused = selected.status === "paused";
219
- const msgKey = isPaused ? "board.confirmResume" : "board.confirmPlay";
220
- const toastKey = isPaused ? "board.toastResumed" : "board.toastPlayed";
221
- const fn = isPaused ? () => resumeLoop(selectedId) : () => playLoop(selectedId);
222
- confirmAction(t(msgKey, { desc: selectedDesc }), t(toastKey, { desc: selectedDesc }), fn);
223
- return;
224
- }
225
- if (action === "trigger") {
226
- confirmAction(t("board.confirmTrigger", { desc: selectedDesc }), t("board.toastTriggered", { desc: selectedDesc }), () => triggerLoop(selectedId));
227
- return;
228
- }
229
- }
230
- function handleTaskAction(action) {
231
- if (!selectedTask)
232
- return;
233
- if (action === "select") {
234
- if (!stack.includes("create") && !stack.includes("task-edit"))
235
- return;
236
- setPendingTaskSelection({ id: selectedTask.id, name: selectedTask.name });
237
- pop();
238
- pushToast("success", t("board.toastTaskSelected", { desc: selectedTask.name }));
239
- return;
240
- }
241
- if (action === "edit") {
242
- setEditTask(selectedTask);
243
- push("task-edit");
244
- return;
245
- }
246
- if (action === "delete") {
247
- setConfirmChoice(0);
248
- setConfirm({
249
- message: t("board.confirmDeleteTask", { desc: selectedTask.name }),
250
- action: async () => {
251
- await deleteTask(selectedTask.id);
252
- await refreshTasks();
253
- setTaskSelectedIndex((i) => Math.max(0, i - 1));
254
- pushToast("success", t("board.toastTaskDeleted", { desc: selectedTask.name }));
255
- },
256
- });
257
- }
258
- }
259
- const cancelCreate = () => { setEditTarget(null); setCloneMode(false); setPendingTaskSelection(null); pop(); };
260
- const cancelTask = () => { setEditTask(null); pop(); };
261
- const cancelTaskList = () => pop();
262
- function handleChooseTask() {
263
- void refreshTasks();
264
- push("task-list");
265
- }
266
- function handleCreateTask() {
267
- setEditTask(null);
268
- push("task-create");
269
- }
270
- const onCreateDone = (updated, _id, desc) => {
271
- setEditTarget(null);
272
- setCloneMode(false);
273
- setPendingTaskSelection(null);
274
- pop();
275
- pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
276
- void refresh();
277
- };
278
- const onTaskDone = (updated, id) => {
279
- setEditTask(null);
280
- void refreshTasks();
281
- pop();
282
- pushToast("success", updated ? t("board.toastTaskUpdated", { id }) : t("board.toastTaskCreated", { id }));
283
- };
284
- const FORM_VIEWS = ["create", "task-create", "task-edit"];
285
- const isFormView = FORM_VIEWS.includes(view);
286
- const actionKeys = selected ? getActionKeys(selected.status) : [];
287
- const actionItems = actionKeys.map((k) => `action-${k}`);
288
- const boardItems = ["search", "project-filter", "status", "sort", "header-1", "header-2", "header-3", "navigator", "run-history", ...actionItems];
289
- const taskItems = ["task-search", "header-1", "header-2", "header-3", "task-list", ...(["select", "edit", "delete"].map((k) => `task-action-${k}`))];
290
- const projectItems = ["header-1", "header-2", "header-3"];
291
- const navItems = isFormView ? [] : view === "task-list" ? taskItems : view === "projects" ? projectItems : boardItems;
292
- const { focusedItem: navFocused, setFocusIndex: setNavIndex, enabled: navEnabled } = useTabNav(navItems);
293
- const HEADER_MAP = { "header-1": "header-tasks", "header-2": "header-projects", "header-3": "header-new" };
294
- const isHeaderFocused = navFocused === "header-1" || navFocused === "header-2" || navFocused === "header-3";
295
- const derivedFocusedPanel = isHeaderFocused ? HEADER_MAP[navFocused] : navFocused ?? "loops";
296
- const actionIdx = navFocused?.startsWith("action-") ? actionKeys.indexOf(navFocused.slice("action-".length)) : -1;
297
- const derivedSelectedAction = actionIdx >= 0 ? actionIdx : selectedAction;
298
- const taskActionIdx = navFocused?.startsWith("task-action-") ? ["select", "edit", "delete"].indexOf(navFocused.slice("task-action-".length)) : -1;
299
- const derivedTaskAction = taskActionIdx >= 0 ? taskActionIdx : taskSelectedAction;
300
- const taskPanelMap = {
301
- "task-search": "search",
302
- "task-list": "tasks",
303
- "task-action-select": "actions",
304
- "task-action-edit": "actions",
305
- "task-action-delete": "actions",
306
- };
307
- const derivedTaskPanel = (navFocused ? taskPanelMap[navFocused] : undefined) ?? "tasks";
308
- useBoardKeybindings({
309
- confirm,
310
- confirmChoice,
311
- setConfirm,
312
- setConfirmChoice,
313
- helpOpen,
314
- setHelpOpen,
315
- searchActive,
316
- setSearchActive,
317
- view,
318
- push,
319
- pop,
320
- setEditTarget,
321
- setEditTask,
322
- selected,
323
- visibleCount: visible.length,
324
- setSelectedIndex,
325
- setFilters,
326
- setSort,
327
- onQuit: props.onQuit,
328
- destroyLogSocket,
329
- logModalRun,
330
- setLogModalRun,
331
- logModalLines,
332
- selectedRunIndex,
333
- setSelectedRunIndex,
334
- selectedRunCount: selected?.runHistory?.length ?? 0,
335
- focusedPanel: derivedFocusedPanel,
336
- setFocusedPanel: ((p) => {
337
- const navIdx = navItems.indexOf(p);
338
- if (navIdx >= 0)
339
- setNavIndex(navIdx);
340
- }),
341
- selectedAction: derivedSelectedAction,
342
- setSelectedAction,
343
- onAction: handleAction,
344
- onOpenRunLog: handleOpenRunLog,
345
- refreshTasks,
346
- onViewTasks: () => { void refreshTasks(); push("task-list"); },
347
- onViewProjects: () => { void refreshProjects(); push("projects"); },
348
- onViewLoops: () => replace("board"),
349
- onAddLoop: () => { setEditTarget(null); push("create"); },
350
- onAddTask: () => { setEditTask(null); push("task-create"); },
351
- onSelectProject: () => setProjectsModalOpen(true),
352
- });
353
- useTaskKeybindings({
354
- confirm,
355
- view,
356
- tasks: filteredTasks,
357
- taskSelectedIndex,
358
- setTaskSelectedIndex,
359
- taskSelectedAction: derivedTaskAction,
360
- setTaskSelectedAction: ((updater) => {
361
- const nextIdx = typeof updater === "function" ? updater(derivedTaskAction) : updater;
362
- const taskActions = ["select", "edit", "delete"];
363
- const actionKey = taskActions[nextIdx];
364
- if (actionKey) {
365
- const navIdx = navItems.indexOf(`task-action-${actionKey}`);
366
- if (navIdx >= 0)
367
- setNavIndex(navIdx);
368
- }
369
- }),
370
- taskFocusedPanel: derivedTaskPanel,
371
- setTaskFocusedPanel: ((p) => {
372
- const navIdx = navItems.indexOf(p === "search" ? "task-search" : p === "actions" ? "task-actions" : "task-list");
373
- if (navIdx >= 0)
374
- setNavIndex(navIdx);
375
- }),
376
- taskSearchActive,
377
- setTaskSearchActive,
378
- taskQuery,
379
- setTaskQuery,
380
- onTaskAction: handleTaskAction,
381
- onCancel: cancelTaskList,
382
- onCreateTask: handleCreateTask,
383
- onToggleContextHelp: () => setContextHelpOpen((v) => !v),
384
- headerFocused: isHeaderFocused,
385
- selectable: stack.includes("create") || stack.includes("task-edit"),
386
- });
387
- const counts = {
388
- total: loops.length,
389
- running: loops.filter((l) => l.status === "running").length,
390
- waiting: loops.filter((l) => l.status === "waiting").length,
391
- paused: loops.filter((l) => l.status === "paused").length,
392
- idle: loops.filter((l) => l.status === "idle").length,
393
- };
394
- const mode = resolveMode(confirm, searchActive || taskSearchActive, helpOpen, view);
395
- return (_jsxs("box", { style: { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#0b0b0b" }, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, view: view, focusedPanel: derivedFocusedPanel, onViewLoops: () => replace("board"), onViewTasks: () => { void refreshTasks(); push("task-list"); }, onViewProjects: () => push("projects"), onAddLoop: () => { setEditTarget(null); push("create"); }, onAddTask: () => { setEditTask(null); push("task-create"); }, onAddProject: () => { if (view === "projects") {
396
- createProjectTriggerRef.current?.();
397
- }
398
- else {
399
- push("projects");
400
- } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: derivedFocusedPanel, 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); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: derivedTaskPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); } })) : 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, onCopy: () => pushToast("success", t("board.toastCopied")) })) : 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: derivedTaskPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); }, 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: taskActionIdx >= 0, selectedAction: derivedTaskAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, headerFocused: isHeaderFocused, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: () => { } })) : (_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: derivedFocusedPanel === "loops" || derivedFocusedPanel === "navigator", projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
401
- setEditTarget(loop);
402
- push("create");
403
- } } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: derivedFocusedPanel === "runs" || derivedFocusedPanel === "run-history", onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: actionIdx >= 0, selectedAction: derivedSelectedAction, 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, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : 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 })] }));
404
- }
405
- const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);
@@ -1,18 +0,0 @@
1
- import fs from "node:fs";
2
- import { getBoardLogFile, getDataDir } from "../config/paths.js";
3
- function formatError(error) {
4
- if (error instanceof Error) {
5
- return error.stack ?? `${error.name}: ${error.message}`;
6
- }
7
- return typeof error === "string" ? error : JSON.stringify(error);
8
- }
9
- export function boardLog(message, error) {
10
- try {
11
- fs.mkdirSync(getDataDir(), { recursive: true });
12
- const suffix = error === undefined ? "" : `\n${formatError(error)}\n`;
13
- fs.appendFileSync(getBoardLogFile(), `${new Date().toISOString()} ${message}${suffix}`);
14
- }
15
- catch {
16
- // best effort crash logging only
17
- }
18
- }
@@ -1,46 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { t } from "../../i18n/index.js";
3
- import { useHoverState } from "../hooks/useHoverState.js";
4
- import { HOVER_BG } from "../../config/constants.js";
5
- const STOP_ACTION = { key: "stop", label: t("board.actionStop") };
6
- const PAUSE_ACTION = { key: "pause", label: t("board.actionPause") };
7
- const PLAY_ACTION = { key: "play", label: t("board.actionPlay") };
8
- const STATUS_ACTIONS = {
9
- running: [STOP_ACTION],
10
- waiting: [PAUSE_ACTION, STOP_ACTION],
11
- paused: [STOP_ACTION, PLAY_ACTION],
12
- idle: [PLAY_ACTION],
13
- stopped: [PLAY_ACTION],
14
- };
15
- function getActions(status) {
16
- const actions = [
17
- { key: "edit", label: t("board.actionEdit") },
18
- { key: "delete", label: t("board.actionDelete") },
19
- ];
20
- actions.push(...STATUS_ACTIONS[status]);
21
- if (status !== "running") {
22
- actions.push({ key: "clone", label: t("board.actionClone") });
23
- actions.push({ key: "trigger", label: t("board.actionTrigger") });
24
- }
25
- return actions;
26
- }
27
- export function getActionKeys(status) {
28
- return getActions(status).map((a) => a.key);
29
- }
30
- export function getActionCount(status) {
31
- return getActions(status).length;
32
- }
33
- export function ActionButtons(props) {
34
- const { loop, focused, selectedAction, onAction } = props;
35
- if (!loop) {
36
- return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, backgroundColor: "#0b0b0b", justifyContent: "center", alignItems: "center" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.noActions") }) }));
37
- }
38
- const actions = getActions(loop.status);
39
- return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center" }, children: actions.map((action, i) => (_jsx(ActionButton, { label: action.label, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
40
- }
41
- function ActionButton(props) {
42
- const { isHovered, hoverProps } = useHoverState();
43
- const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
44
- const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
45
- return (_jsx("box", { onMouseDown: props.onMouseDown, style: { backgroundColor: bg, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: props.label }) }) }));
46
- }