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,309 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import fs from "node:fs";
5
+ import { buildLoopOptions, parseCommandLine } from "../../loop-config.js";
6
+ import { t } from "../../i18n/index.js";
7
+ import { commandLine } from "../format.js";
8
+ import { createLoop, updateLoop, listTasks } from "../daemon.js";
9
+ import { useHoverState } from "../hooks/useHoverState.js";
10
+ import { HOVER_BG } from "../../config/constants.js";
11
+ export const TASK_MODE_INLINE = "inline";
12
+ export const TASK_MODE_EXISTING = "existing";
13
+ export const createFields = ["interval", "taskMode", "command", "cwd", "taskId", "description", "runNow", "maxRuns", "project"];
14
+ export function createInitialValues(loop, currentProjectId) {
15
+ if (!loop) {
16
+ return {
17
+ interval: "30m",
18
+ taskMode: TASK_MODE_INLINE,
19
+ command: "",
20
+ cwd: process.cwd(),
21
+ taskId: "",
22
+ description: "",
23
+ runNow: "n",
24
+ maxRuns: "",
25
+ project: currentProjectId ?? "default",
26
+ };
27
+ }
28
+ return {
29
+ interval: loop.intervalHuman,
30
+ taskMode: loop.taskId ? TASK_MODE_EXISTING : TASK_MODE_INLINE,
31
+ command: commandLine(loop.command, loop.commandArgs),
32
+ cwd: loop.cwd ?? "",
33
+ taskId: loop.taskId ?? "",
34
+ description: loop.description ?? "",
35
+ runNow: loop.immediate ? "y" : "n",
36
+ maxRuns: loop.maxRuns !== null ? String(loop.maxRuns) : "",
37
+ project: loop.projectId ?? currentProjectId ?? "default",
38
+ };
39
+ }
40
+ export function CreateView(props) {
41
+ const [values, setValues] = useState({
42
+ ...props.initial,
43
+ project: props.currentProjectId ?? props.initial.project ?? "default",
44
+ });
45
+ const valuesRef = useRef(values);
46
+ const [focusIndex, setFocusIndex] = useState(0);
47
+ const [error, setError] = useState("");
48
+ const [isSubmitting, setIsSubmitting] = useState(false);
49
+ const [selectedTaskName, setSelectedTaskName] = useState(props.selectedTaskName ?? null);
50
+ useState(() => {
51
+ if (values.taskId && !selectedTaskName) {
52
+ void listTasks().then((tasks) => {
53
+ const found = tasks.find((t) => t.id === values.taskId);
54
+ if (found)
55
+ setSelectedTaskName(found.name);
56
+ }).catch(() => { });
57
+ }
58
+ });
59
+ const { width: termWidth } = useTerminalDimensions();
60
+ const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
61
+ const isInline = values.taskMode === TASK_MODE_INLINE;
62
+ const visibleFields = props.mode === "edit"
63
+ ? createFields.filter((f) => f !== "runNow")
64
+ : [...createFields];
65
+ const filteredFields = visibleFields.filter((f) => {
66
+ if (f === "command" || f === "cwd")
67
+ return isInline;
68
+ if (f === "taskId")
69
+ return !isInline;
70
+ return true;
71
+ });
72
+ const chooseTaskIdx = filteredFields.indexOf("taskId");
73
+ const saveIndex = filteredFields.length;
74
+ const cancelIndex = filteredFields.length + 1;
75
+ function updateValues(next) {
76
+ valuesRef.current = next;
77
+ setValues(next);
78
+ }
79
+ if (props.selectedTaskId && props.selectedTaskId !== values.taskId) {
80
+ const next = { ...valuesRef.current, taskId: props.selectedTaskId, taskMode: TASK_MODE_EXISTING };
81
+ valuesRef.current = next;
82
+ setValues(next);
83
+ setSelectedTaskName(props.selectedTaskName ?? props.selectedTaskId);
84
+ }
85
+ useKeyboard((key) => {
86
+ if (key.name === "tab") {
87
+ setFocusIndex((i) => {
88
+ const next = key.shift ? i - 1 : i + 1;
89
+ return Math.max(0, Math.min(cancelIndex, next));
90
+ });
91
+ return;
92
+ }
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
+ }
118
+ return;
119
+ }
120
+ }
121
+ if (key.name === "return" || key.name === "enter" || key.name === " ") {
122
+ const field = filteredFields[focusIndex];
123
+ if (field === "taskMode") {
124
+ const next = valuesRef.current.taskMode === TASK_MODE_INLINE ? TASK_MODE_EXISTING : TASK_MODE_INLINE;
125
+ updateValues({ ...valuesRef.current, taskMode: next });
126
+ return;
127
+ }
128
+ if (field === "runNow") {
129
+ const next = valuesRef.current.runNow === "y" ? "n" : "y";
130
+ updateValues({ ...valuesRef.current, runNow: next });
131
+ return;
132
+ }
133
+ if (focusIndex === chooseTaskIdx && !isInline) {
134
+ props.onChooseTask();
135
+ return;
136
+ }
137
+ if (focusIndex === saveIndex) {
138
+ void submit(valuesRef.current);
139
+ }
140
+ else if (focusIndex === cancelIndex) {
141
+ 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;
155
+ }
156
+ }
157
+ });
158
+ async function submit(current) {
159
+ if (isSubmitting)
160
+ return;
161
+ try {
162
+ setIsSubmitting(true);
163
+ setError("");
164
+ if (current.taskMode === TASK_MODE_INLINE) {
165
+ const cwd = current.cwd.trim();
166
+ if (cwd && !fs.existsSync(cwd)) {
167
+ setError(t("board.cwdMissing", { cwd }));
168
+ return;
169
+ }
170
+ const tokens = parseCommandLine(current.command.trim());
171
+ const [command, ...commandArgs] = tokens;
172
+ if (!command) {
173
+ setError(t("errors.commandEmpty"));
174
+ return;
175
+ }
176
+ if (!current.description.trim()) {
177
+ setError(t("errors.descriptionEmpty"));
178
+ return;
179
+ }
180
+ const built = buildLoopOptions(current.interval.trim(), {
181
+ now: props.mode === "create" && current.runNow === "y",
182
+ maxRuns: current.maxRuns.trim() || null,
183
+ verbose: false,
184
+ description: current.description.trim(),
185
+ command,
186
+ commandArgs,
187
+ cwd,
188
+ });
189
+ built.options.projectId = current.project || "default";
190
+ const request = props.mode === "edit" && props.editId
191
+ ? updateLoop(props.editId, built.options, built.intervalHuman)
192
+ : createLoop(built.options, built.intervalHuman);
193
+ const id = await request;
194
+ props.onDone(props.mode === "edit", id, current.description.trim());
195
+ }
196
+ else {
197
+ if (!current.taskId) {
198
+ setError(t("errors.commandEmpty"));
199
+ return;
200
+ }
201
+ if (!current.description.trim()) {
202
+ setError(t("errors.descriptionEmpty"));
203
+ return;
204
+ }
205
+ const built = buildLoopOptions(current.interval.trim(), {
206
+ now: props.mode === "create" && current.runNow === "y",
207
+ maxRuns: current.maxRuns.trim() || null,
208
+ verbose: false,
209
+ description: current.description.trim(),
210
+ taskId: current.taskId,
211
+ });
212
+ built.options.projectId = current.project || "default";
213
+ const request = props.mode === "edit" && props.editId
214
+ ? updateLoop(props.editId, built.options, built.intervalHuman)
215
+ : createLoop(built.options, built.intervalHuman);
216
+ const id = await request;
217
+ props.onDone(props.mode === "edit", id, current.description.trim());
218
+ }
219
+ }
220
+ catch (e) {
221
+ setError(e instanceof Error ? e.message : String(e));
222
+ }
223
+ finally {
224
+ setIsSubmitting(false);
225
+ }
226
+ }
227
+ const labels = {
228
+ interval: t("board.labelInterval"),
229
+ taskMode: t("board.labelTaskMode"),
230
+ command: t("board.labelCommand"),
231
+ cwd: t("board.labelCwd"),
232
+ taskId: t("board.labelTask"),
233
+ description: t("board.labelDescription"),
234
+ runNow: t("board.labelRunNow"),
235
+ maxRuns: t("board.labelMaxRuns"),
236
+ project: t("project.projectsLabel"),
237
+ };
238
+ const hints = {
239
+ interval: t("board.hintInterval"),
240
+ taskMode: t("board.hintTaskMode"),
241
+ command: t("board.hintCommand"),
242
+ cwd: t("board.hintCwd"),
243
+ taskId: t("board.hintTask"),
244
+ description: t("board.hintDescription"),
245
+ runNow: t("board.hintRunNow"),
246
+ maxRuns: t("board.hintMaxRuns"),
247
+ project: t("project.hintColor"),
248
+ };
249
+ const examples = {
250
+ interval: t("board.exampleInterval"),
251
+ taskMode: "",
252
+ command: t("board.exampleCommand"),
253
+ cwd: "",
254
+ taskId: "",
255
+ description: t("board.exampleDescription"),
256
+ runNow: "",
257
+ maxRuns: "",
258
+ project: "",
259
+ };
260
+ const taskModeOptions = [
261
+ { name: t("board.taskModeInline"), description: "", value: TASK_MODE_INLINE },
262
+ { name: t("board.taskModeExisting"), description: "", value: TASK_MODE_EXISTING },
263
+ ];
264
+ const runNowOptions = [
265
+ { name: t("board.runNowNo"), description: "", value: "n" },
266
+ { name: t("board.runNowYes"), description: "", value: "y" },
267
+ ];
268
+ const projectOptions = (props.projects ?? []).length > 0
269
+ ? props.projects.map((p) => ({ name: p.name, value: p.id, color: p.color }))
270
+ : [{ name: "Default", value: "default", color: "#ffffff" }];
271
+ const title = props.mode === "edit" ? t("board.editTitle") : t("board.createTitle");
272
+ return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { style: { flexDirection: "column", marginBottom: 1 }, children: [_jsx("text", { fg: "#9ca3af", children: t("board.exampleHeading") }), _jsx("text", { fg: "#d1d5db", children: t("board.exampleFull") })] }), _jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(filteredFields.length / 2) }, (_, row) => {
273
+ const leftField = filteredFields[row * 2];
274
+ const rightField = row * 2 + 1 < filteredFields.length ? filteredFields[row * 2 + 1] : null;
275
+ return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(FormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(FormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, examples: examples, taskModeOptions: taskModeOptions, runNowOptions: runNowOptions, projectOptions: projectOptions, selectedTaskName: selectedTaskName, fields: filteredFields, onChooseTask: props.onChooseTask, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
276
+ }) }), _jsxs("box", { style: { flexDirection: "row", height: 3, marginBottom: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(HoverButton, { label: isSubmitting ? t("board.saving") : props.mode === "edit" ? t("board.save") : t("board.create"), onMouseDown: () => void submit(valuesRef.current), selected: focusIndex === saveIndex, width: btnWidth, marginRight: 1 }), _jsx(HoverButton, { label: t("board.cancel"), onMouseDown: props.onCancel, selected: focusIndex === cancelIndex, width: btnWidth })] }), _jsx("text", { fg: "#9ca3af", children: t("board.formNav") }), error ? _jsx("text", { fg: "#f87171", children: error }) : null] }));
277
+ }
278
+ function HoverButton(props) {
279
+ const { isHovered, hoverProps } = useHoverState();
280
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
281
+ const borderColor = props.selected ? "#38bdf8" : undefined;
282
+ return (_jsx("box", { border: true, onMouseDown: props.onMouseDown, borderColor: borderColor, style: { width: props.width, justifyContent: "center", alignItems: "center", marginRight: props.marginRight, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: props.label }) }) }));
283
+ }
284
+ function FormRow(props) {
285
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, style } = props;
286
+ const { isHovered, hoverProps } = useHoverState();
287
+ const isFocused = focusIndex === index;
288
+ const isToggleField = field === "taskMode" || field === "runNow";
289
+ const isTaskButton = field === "taskId";
290
+ const isProjectField = field === "project";
291
+ const toggleOptions = field === "taskMode" ? taskModeOptions : runNowOptions;
292
+ const toggleValue = field === "taskMode" ? values.taskMode : values.runNow;
293
+ return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isToggleField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center" }, children: toggleOptions.map((opt) => {
294
+ const isActive = toggleValue === opt.value;
295
+ return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, [field]: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, ...hoverProps, children: _jsx("text", { fg: isActive ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af", children: _jsx("strong", { children: opt.name }) }) }, opt.value));
296
+ }) })) : isTaskButton ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => { setFocusIndex(index); onChooseTask(); }, children: _jsx("text", { fg: values.taskId ? "#4ade80" : "#9ca3af", children: values.taskId
297
+ ? t("board.selectedTask", { name: selectedTaskName ?? values.taskId })
298
+ : t("board.chooseTask") }) })) : isProjectField ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", paddingLeft: 1 }, onMouseDown: () => setFocusIndex(index), children: projectOptions.map((opt) => {
299
+ const isActive = values.project === opt.value;
300
+ return (_jsx("box", { onMouseDown: () => updateValues({ ...valuesRef.current, project: opt.value }), style: { backgroundColor: isActive ? "#1e3a8a" : undefined, paddingLeft: 1, paddingRight: 1, marginRight: 1 }, children: _jsx("text", { fg: isActive ? "#ffffff" : "#9ca3af", children: `● ${opt.name}` }) }, opt.value));
301
+ }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: examples[field] ? t("board.placeholderExample", { example: examples[field] }) : t("board.placeholderBlank"), onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
302
+ if (index < fields.length - 1) {
303
+ setFocusIndex(index + 1);
304
+ }
305
+ else {
306
+ void submit(valuesRef.current);
307
+ }
308
+ } }) }))] }));
309
+ }
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { createProject } from "../daemon.js";
6
+ import { PROJECT_COLORS, PROJECT_COLOR_KEYS } from "../../config/constants.js";
7
+ export function CreateProjectModal(props) {
8
+ const { onDone, onCancel } = props;
9
+ const { width } = useTerminalDimensions();
10
+ const defaultColorKey = PROJECT_COLOR_KEYS.indexOf("cyan") >= 0 ? "cyan" : PROJECT_COLOR_KEYS[0] ?? "white";
11
+ const [name, setName] = useState("");
12
+ const [selectedColorKey, setSelectedColorKey] = useState(defaultColorKey);
13
+ const [error, setError] = useState("");
14
+ const [isSubmitting, setIsSubmitting] = useState(false);
15
+ const [focusField, setFocusField] = useState("name");
16
+ useKeyboard((key) => {
17
+ if (key.name === "tab") {
18
+ const order = ["name", "color", "save", "cancel"];
19
+ const idx = order.indexOf(focusField);
20
+ const next = key.shift ? order[(idx - 1 + order.length) % order.length] : order[(idx + 1) % order.length];
21
+ setFocusField(next ?? "name");
22
+ return;
23
+ }
24
+ if (key.name === "escape") {
25
+ onCancel();
26
+ return;
27
+ }
28
+ if (key.name === "return" || key.name === "enter") {
29
+ if (focusField === "cancel") {
30
+ onCancel();
31
+ return;
32
+ }
33
+ void submit();
34
+ return;
35
+ }
36
+ if (focusField === "color") {
37
+ if (key.name === "left") {
38
+ const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
39
+ setSelectedColorKey(PROJECT_COLOR_KEYS[(idx - 1 + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
40
+ return;
41
+ }
42
+ if (key.name === "right") {
43
+ const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
44
+ setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + 1) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
45
+ return;
46
+ }
47
+ }
48
+ });
49
+ async function submit() {
50
+ if (isSubmitting)
51
+ return;
52
+ if (!name.trim()) {
53
+ setError(t("project.error.nameRequired"));
54
+ return;
55
+ }
56
+ try {
57
+ setIsSubmitting(true);
58
+ setError("");
59
+ const color = PROJECT_COLORS[selectedColorKey] ?? "#ffffff";
60
+ const project = await createProject(name.trim(), color);
61
+ onDone(project);
62
+ }
63
+ catch (err) {
64
+ setError(err instanceof Error ? err.message : String(err));
65
+ }
66
+ finally {
67
+ setIsSubmitting(false);
68
+ }
69
+ }
70
+ return (_jsx("box", { style: {
71
+ position: "absolute",
72
+ top: 0,
73
+ left: 0,
74
+ width: "100%",
75
+ height: "100%",
76
+ justifyContent: "center",
77
+ alignItems: "center",
78
+ zIndex: 100,
79
+ }, children: _jsxs("box", { title: t("project.createTitle"), border: true, style: {
80
+ flexDirection: "column",
81
+ padding: 1,
82
+ minWidth: Math.min(44, width - 4),
83
+ backgroundColor: "#111827",
84
+ }, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: "My Project", onInput: (value) => setName(value), onSubmit: () => setFocusField("color") }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
85
+ const isActive = selectedColorKey === colorKey;
86
+ return (_jsx("box", { onMouseDown: () => { setFocusField("color"); setSelectedColorKey(colorKey); }, style: {
87
+ backgroundColor: isActive ? "#1e3a8a" : undefined,
88
+ paddingLeft: 1,
89
+ paddingRight: 1,
90
+ marginRight: 1,
91
+ }, children: _jsxs("text", { children: [_jsx("span", { fg: PROJECT_COLORS[colorKey] ?? "#ffffff", children: "\u25CF" }), ` ${colorKey}`] }) }, colorKey));
92
+ }) }), error ? _jsx("text", { fg: "#f87171", children: error }) : null, _jsxs("box", { style: { flexDirection: "row", marginTop: 1 }, children: [_jsx("box", { border: true, borderColor: focusField === "save" ? "#38bdf8" : undefined, onMouseDown: () => void submit(), style: {
93
+ width: 12,
94
+ justifyContent: "center",
95
+ alignItems: "center",
96
+ marginRight: 1,
97
+ backgroundColor: focusField === "save" ? "#1e3a8a" : "#0b0b0b",
98
+ }, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: isSubmitting ? t("board.saving") : t("board.create") }) }) }), _jsx("box", { border: true, borderColor: focusField === "cancel" ? "#38bdf8" : undefined, onMouseDown: onCancel, style: {
99
+ width: 12,
100
+ justifyContent: "center",
101
+ alignItems: "center",
102
+ backgroundColor: focusField === "cancel" ? "#1e3a8a" : "#0b0b0b",
103
+ }, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: t("board.cancel") }) }) })] })] }) }));
104
+ }
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { deleteProject } from "../daemon.js";
6
+ export function DeleteProjectConfirm(props) {
7
+ const { project, loopCount, onDone, onCancel } = props;
8
+ const { width } = useTerminalDimensions();
9
+ const [isDeleting, setIsDeleting] = useState(false);
10
+ const [error, setError] = useState("");
11
+ useKeyboard((key) => {
12
+ if (key.name === "escape") {
13
+ onCancel();
14
+ }
15
+ if (key.name === "return" || key.name === "enter") {
16
+ void handleDelete();
17
+ }
18
+ });
19
+ async function handleDelete() {
20
+ if (isDeleting)
21
+ return;
22
+ try {
23
+ setIsDeleting(true);
24
+ setError("");
25
+ await deleteProject(project.id);
26
+ onDone();
27
+ }
28
+ catch (err) {
29
+ setError(err instanceof Error ? err.message : String(err));
30
+ setIsDeleting(false);
31
+ }
32
+ }
33
+ return (_jsx("box", { style: {
34
+ position: "absolute",
35
+ top: 0,
36
+ left: 0,
37
+ width: "100%",
38
+ height: "100%",
39
+ justifyContent: "center",
40
+ alignItems: "center",
41
+ zIndex: 100,
42
+ }, children: _jsxs("box", { title: t("project.deleteTitle"), border: true, style: {
43
+ flexDirection: "column",
44
+ padding: 1,
45
+ minWidth: Math.min(50, width - 4),
46
+ backgroundColor: "#111827",
47
+ }, children: [_jsx("text", { fg: "#e5e7eb", children: t("project.deleteConfirm", { name: project.name }) }), loopCount > 0 ? (_jsx("text", { fg: "#f59e0b", children: t("project.deleteWarning", { count: String(loopCount) }) })) : null, error ? _jsx("text", { fg: "#f87171", children: error }) : null, _jsx("text", { children: " " }), _jsxs("box", { style: { flexDirection: "row" }, children: [_jsx("box", { border: true, onMouseDown: () => void handleDelete(), style: {
48
+ paddingLeft: 1,
49
+ paddingRight: 1,
50
+ marginRight: 1,
51
+ backgroundColor: "#7f1d1d",
52
+ }, children: _jsx("text", { fg: "#fca5a5", children: _jsx("strong", { children: isDeleting ? "Deleting..." : t("project.moveToDefault") }) }) }), _jsx("box", { border: true, onMouseDown: onCancel, style: { paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: t("board.cancel") }) }) })] })] }) }));
53
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { t } from "../../i18n/index.js";
4
+ import { commandLine, describeLoop, statusColor, statusLabel, timingLabel } from "../format.js";
5
+ import { RunHistory } from "./RunHistory.js";
6
+ export function DetailView(props) {
7
+ const { loop, selectedRunIndex, onSelectRun, onOpenRun } = props;
8
+ const [, setTick] = useState(0);
9
+ const cmd = commandLine(loop.command, loop.commandArgs);
10
+ const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("board.unlimited");
11
+ const summary = [
12
+ `${t("board.detailSummaryStatus")} ${statusLabel(loop.status)}`,
13
+ `${t("board.detailSummaryTiming")} ${timingLabel(loop)}`,
14
+ `${t("board.detailSummaryInterval")} ${loop.intervalHuman}`,
15
+ `${t("board.detailSummaryRuns")} ${loop.runCount}/${maxRuns}`,
16
+ `${t("board.detailSummaryExit")} ${loop.lastExitCode ?? t("format.dash")}`,
17
+ ].join(" | ");
18
+ useEffect(() => {
19
+ const timer = setInterval(() => {
20
+ setTick((tick) => tick + 1);
21
+ }, 1000);
22
+ return () => clearInterval(timer);
23
+ }, []);
24
+ return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsxs("box", { title: t("board.detailTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsx("text", { fg: "#9ca3af", children: summary }), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldId") }), loop.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldDesc") }), describeLoop(loop)] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldCommand") }), cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldDir") }), loop.cwd || t("board.inherit")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldInterval") }), loop.intervalHuman] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldStatus") }), _jsx("span", { fg: statusColor(loop.status), children: statusLabel(loop.status) })] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldRuns") }), loop.runCount, " / ", maxRuns] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldCreated") }), loop.createdAt] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldLastRun") }), loop.lastRunAt ?? t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldLastExit") }), loop.lastExitCode ?? t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldNextRun") }), loop.nextRunAt ?? t("format.dash")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.detailFieldPid") }), loop.pid] })] }), _jsx(RunHistory, { loop: loop, selectedRunIndex: selectedRunIndex, focused: true, onSelectRun: onSelectRun, onOpenRun: onOpenRun })] }));
25
+ }
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { updateProject } from "../daemon.js";
6
+ import { PROJECT_COLORS, PROJECT_COLOR_KEYS } from "../../config/constants.js";
7
+ export function EditProjectModal(props) {
8
+ const { project, onDone, onCancel } = props;
9
+ const { width } = useTerminalDimensions();
10
+ const [name, setName] = useState(project.name);
11
+ const [selectedColorKey, setSelectedColorKey] = useState(() => {
12
+ const found = PROJECT_COLOR_KEYS.find((k) => PROJECT_COLORS[k] === project.color);
13
+ return found ?? PROJECT_COLOR_KEYS[0] ?? "white";
14
+ });
15
+ const [error, setError] = useState("");
16
+ const [isSubmitting, setIsSubmitting] = useState(false);
17
+ const [focusField, setFocusField] = useState("name");
18
+ useKeyboard((key) => {
19
+ if (key.name === "tab") {
20
+ const order = ["name", "color", "save", "cancel"];
21
+ const idx = order.indexOf(focusField);
22
+ const next = key.shift ? order[(idx - 1 + order.length) % order.length] : order[(idx + 1) % order.length];
23
+ setFocusField(next ?? "name");
24
+ return;
25
+ }
26
+ if (key.name === "escape") {
27
+ onCancel();
28
+ return;
29
+ }
30
+ if (key.name === "return" || key.name === "enter") {
31
+ if (focusField === "cancel") {
32
+ onCancel();
33
+ return;
34
+ }
35
+ void submit();
36
+ return;
37
+ }
38
+ if (focusField === "color") {
39
+ if (key.name === "left") {
40
+ const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
41
+ setSelectedColorKey(PROJECT_COLOR_KEYS[(idx - 1 + PROJECT_COLOR_KEYS.length) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
42
+ return;
43
+ }
44
+ if (key.name === "right") {
45
+ const idx = PROJECT_COLOR_KEYS.indexOf(selectedColorKey);
46
+ setSelectedColorKey(PROJECT_COLOR_KEYS[(idx + 1) % PROJECT_COLOR_KEYS.length] ?? selectedColorKey);
47
+ return;
48
+ }
49
+ }
50
+ });
51
+ async function submit() {
52
+ if (isSubmitting)
53
+ return;
54
+ if (!name.trim()) {
55
+ setError(t("project.error.nameEmpty"));
56
+ return;
57
+ }
58
+ try {
59
+ setIsSubmitting(true);
60
+ setError("");
61
+ const color = PROJECT_COLORS[selectedColorKey] ?? project.color;
62
+ await updateProject(project.id, name.trim(), color);
63
+ onDone();
64
+ }
65
+ catch (err) {
66
+ setError(err instanceof Error ? err.message : String(err));
67
+ }
68
+ finally {
69
+ setIsSubmitting(false);
70
+ }
71
+ }
72
+ return (_jsx("box", { style: {
73
+ position: "absolute",
74
+ top: 0,
75
+ left: 0,
76
+ width: "100%",
77
+ height: "100%",
78
+ justifyContent: "center",
79
+ alignItems: "center",
80
+ zIndex: 100,
81
+ }, children: _jsxs("box", { title: t("project.editTitle"), border: true, style: {
82
+ flexDirection: "column",
83
+ padding: 1,
84
+ minWidth: Math.min(44, width - 4),
85
+ backgroundColor: "#111827",
86
+ }, children: [_jsx("text", { fg: focusField === "name" ? "#38bdf8" : "#e5e7eb", children: t("project.labelName") }), _jsx("text", { fg: "#6b7280", children: t("project.hintName") }), _jsx("box", { border: true, borderColor: focusField === "name" ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b", marginBottom: 1 }, children: _jsx("input", { focused: focusField === "name", value: name, placeholder: project.name, onInput: (value) => setName(value), onSubmit: () => setFocusField("color") }) }), _jsx("text", { fg: focusField === "color" ? "#38bdf8" : "#e5e7eb", children: t("project.labelColor") }), _jsx("text", { fg: "#6b7280", children: t("project.hintColor") }), _jsx("box", { border: true, borderColor: focusField === "color" ? "#38bdf8" : undefined, style: { height: 3, flexDirection: "row", backgroundColor: "#0b0b0b", alignItems: "center", marginBottom: 1 }, children: PROJECT_COLOR_KEYS.map((colorKey) => {
87
+ const isActive = selectedColorKey === colorKey;
88
+ return (_jsx("box", { onMouseDown: () => { setFocusField("color"); setSelectedColorKey(colorKey); }, style: {
89
+ backgroundColor: isActive ? "#1e3a8a" : undefined,
90
+ paddingLeft: 1,
91
+ paddingRight: 1,
92
+ marginRight: 1,
93
+ }, children: _jsxs("text", { children: [_jsx("span", { fg: PROJECT_COLORS[colorKey] ?? "#ffffff", children: "\u25CF" }), ` ${colorKey}`] }) }, colorKey));
94
+ }) }), error ? _jsx("text", { fg: "#f87171", children: error }) : null, _jsxs("box", { style: { flexDirection: "row", marginTop: 1 }, children: [_jsx("box", { border: true, borderColor: focusField === "save" ? "#38bdf8" : undefined, onMouseDown: () => void submit(), style: {
95
+ width: 12,
96
+ justifyContent: "center",
97
+ alignItems: "center",
98
+ marginRight: 1,
99
+ backgroundColor: focusField === "save" ? "#1e3a8a" : "#0b0b0b",
100
+ }, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: isSubmitting ? t("board.saving") : t("board.save") }) }) }), _jsx("box", { border: true, borderColor: focusField === "cancel" ? "#38bdf8" : undefined, onMouseDown: onCancel, style: {
101
+ width: 12,
102
+ justifyContent: "center",
103
+ alignItems: "center",
104
+ backgroundColor: focusField === "cancel" ? "#1e3a8a" : "#0b0b0b",
105
+ }, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: t("board.cancel") }) }) })] })] }) }));
106
+ }
@@ -0,0 +1,16 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/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
+ export function FilterBar(props) {
6
+ const { filters, sort, searchActive, focusedPanel, onStatusCycle, onSortCycle, onSelectProject, currentProjectName } = props;
7
+ 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
+ }
10
+ function ClickableBadge(props) {
11
+ const { isHovered, hoverProps } = useHoverState();
12
+ const bg = props.focused ? "#1e2a4a" : isHovered ? HOVER_BG : "#0b0b0b";
13
+ const borderColor = props.focused ? "#38bdf8" : undefined;
14
+ const titleProp = props.title ? { title: props.title } : {};
15
+ return (_jsx("box", { ...titleProp, border: true, borderColor: borderColor, onMouseDown: props.onMouseDown, style: { flexGrow: 1, height: 3, marginRight: props.marginRight, paddingLeft: 1, backgroundColor: bg }, ...hoverProps, children: _jsx("text", { fg: props.textColor, children: props.text }) }));
16
+ }