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,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { useTerminalDimensions } from "@opentui/react";
4
+ import { t } from "../../i18n/index.js";
5
+ import { commandLine, truncate } from "../format.js";
6
+ import { useHoverState } from "../hooks/useHoverState.js";
7
+ import { HOVER_BG, ENTITY_COLORS } from "../../config/constants.js";
8
+ function fit(text, width) {
9
+ if (width <= 0)
10
+ return "";
11
+ if (text.length <= width)
12
+ return text.padEnd(width);
13
+ return truncate(text, width);
14
+ }
15
+ const TASK_PANEL_ORDER = ["search", "new", "tasks", "actions"];
16
+ const TASK_ACTIONS = ["select", "edit", "delete"];
17
+ export const TASK_ACTION_COUNT = TASK_ACTIONS.length;
18
+ export const TASK_ACTION_KEYS = [...TASK_ACTIONS];
19
+ export function nextTaskPanel(current, direction) {
20
+ const idx = TASK_PANEL_ORDER.indexOf(current);
21
+ return TASK_PANEL_ORDER[(idx + (direction === "right" ? 1 : TASK_PANEL_ORDER.length - 1)) % TASK_PANEL_ORDER.length];
22
+ }
23
+ export function TaskNavigator(props) {
24
+ const { visible, total, selectedIndex, focused, query, onSelect, onActivate } = props;
25
+ const { width, height } = useTerminalDimensions();
26
+ const scrollRef = useRef(null);
27
+ const panelWidth = width * 0.55 - 4;
28
+ const panelHeight = height - 7;
29
+ const nameW = 20;
30
+ const chainsW = 14;
31
+ const fixedOverhead = 2 + nameW + 2 + chainsW;
32
+ const cmdW = Math.max(10, panelWidth - fixedOverhead);
33
+ const header = " " +
34
+ fit(t("board.taskHeaderName"), nameW) +
35
+ " " +
36
+ fit(t("board.taskHeaderCommand"), cmdW) +
37
+ " " +
38
+ t("board.taskHeaderChains");
39
+ useEffect(() => {
40
+ const id = `task-row-${selectedIndex}`;
41
+ scrollRef.current?.scrollChildIntoView(id);
42
+ }, [selectedIndex]);
43
+ return (_jsxs("box", { title: t("board.taskBrowserTitle", { visible: visible.length, total }), border: true, borderColor: focused ? ENTITY_COLORS.task : "#2e2545", style: { width: "55%", flexShrink: 0, flexDirection: "column", backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx("text", { fg: "#6b7280", children: header }), visible.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.taskBrowserEmpty") })) : (_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, maxHeight: panelHeight, backgroundColor: "#0b0b0b" }, children: visible.map((task, index) => (_jsx(TaskNavRow, { id: `task-row-${index}`, task: task, index: index, isSelected: index === selectedIndex, focused: focused, nameW: nameW, cmdW: cmdW, chainsW: chainsW, onSelect: onSelect, onActivate: onActivate }, task.id))) }))] }));
44
+ }
45
+ function TaskNavRow(props) {
46
+ const { id, task, index, isSelected, focused, nameW, cmdW, chainsW, onSelect, onActivate } = props;
47
+ const { isHovered, hoverProps } = useHoverState();
48
+ const bg = isSelected ? (focused ? "#1e3a8a" : "#1e2a4a") : isHovered ? HOVER_BG : undefined;
49
+ const fg = isSelected ? "#ffffff" : "#e5e7eb";
50
+ const lastClickRef = useRef(0);
51
+ function handleClick() {
52
+ const now = Date.now();
53
+ if (now - lastClickRef.current < 400) {
54
+ onActivate(index);
55
+ }
56
+ else {
57
+ onSelect(index);
58
+ }
59
+ lastClickRef.current = now;
60
+ }
61
+ const cmd = commandLine(task.command, task.commandArgs);
62
+ const chains = task.onSuccessTaskId || task.onFailureTaskId
63
+ ? t("board.taskChainsFormat", { success: task.onSuccessTaskId ?? "-", failure: task.onFailureTaskId ?? "-" })
64
+ : t("board.taskChainsNone");
65
+ const name = task.name.length > nameW ? task.name.slice(0, nameW - 3) + "..." : task.name;
66
+ const truncCmd = cmd.length > cmdW ? cmd.slice(0, cmdW - 3) + "..." : cmd;
67
+ return (_jsx("box", { id: id, onMouseDown: handleClick, backgroundColor: bg, ...hoverProps, children: _jsxs("text", { fg: fg, children: [isSelected ? "›" : " ", " ", fit(name, nameW), " ", fit(truncCmd, cmdW), " ", fit(chains, chainsW)] }) }));
68
+ }
69
+ export function TaskInspector(props) {
70
+ const { task } = props;
71
+ if (!task) {
72
+ return (_jsx("box", { title: t("board.inspectorTitle"), border: true, style: { backgroundColor: "#0b0b0b" }, children: _jsx("text", { fg: "#9ca3af", children: t("board.inspectorEmpty") }) }));
73
+ }
74
+ const cmd = commandLine(task.command, task.commandArgs);
75
+ return (_jsxs("box", { title: t("board.inspectorTitle"), border: true, style: { flexDirection: "column", backgroundColor: "#0b0b0b" }, children: [_jsxs("text", { children: [_jsx("strong", { children: t("board.fieldId") }), " ", task.id] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelName") }), " ", task.name] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldCommand") }), " ", cmd] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.fieldDir") }), " ", task.cwd || t("board.inherit")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnSuccess") }), " ", task.onSuccessTaskId ?? t("board.taskNone")] }), _jsxs("text", { children: [_jsx("strong", { children: t("board.taskLabelOnFailure") }), " ", task.onFailureTaskId ?? t("board.taskNone")] })] }));
76
+ }
77
+ export function TaskActionButtons(props) {
78
+ const { task, focused, selectedAction, selectable = true, onAction } = props;
79
+ if (!task) {
80
+ 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") }) }));
81
+ }
82
+ const allActions = [
83
+ { key: "select", label: t("board.taskActionSelect") },
84
+ { key: "edit", label: t("board.taskActionEdit") },
85
+ { key: "delete", label: t("board.taskActionDelete") },
86
+ ];
87
+ const actions = selectable ? allActions : allActions.filter((a) => a.key !== "select");
88
+ 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(TaskActionButton, { label: action.label, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
89
+ }
90
+ function TaskActionButton(props) {
91
+ const { isHovered, hoverProps } = useHoverState();
92
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
93
+ const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
94
+ 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 }) }) }));
95
+ }
@@ -0,0 +1,6 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ export function TaskFilterBar(props) {
4
+ const { query, searchActive, focusedPanel } = props;
5
+ return (_jsx("box", { style: { flexDirection: "row", height: 3, paddingLeft: 1, paddingRight: 1, backgroundColor: "#0b0b0b" }, children: _jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focusedPanel === "search" ? "#38bdf8" : undefined, style: { flexGrow: 1, height: 3, paddingLeft: 1, backgroundColor: focusedPanel === "search" ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsxs("text", { fg: query ? "#e5e7eb" : "#6b7280", children: [query || t("board.searchPlaceholder"), "\u258E"] })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", children: query || t("board.searchEmpty") })) }) }));
6
+ }
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import crypto from "node:crypto";
4
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
+ import { t } from "../../i18n/index.js";
6
+ import { createTask, updateTask, listTasks } from "../daemon.js";
7
+ import { useHoverState } from "../hooks/useHoverState.js";
8
+ import { HOVER_BG } from "../../config/constants.js";
9
+ const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
10
+ function taskInitialValues(task) {
11
+ if (!task) {
12
+ return { name: "", command: "", onSuccessTaskId: "", onFailureTaskId: "" };
13
+ }
14
+ return {
15
+ name: task.name,
16
+ command: [task.command, ...task.commandArgs].join(" "),
17
+ onSuccessTaskId: task.onSuccessTaskId ?? "",
18
+ onFailureTaskId: task.onFailureTaskId ?? "",
19
+ };
20
+ }
21
+ export function TaskForm(props) {
22
+ const [values, setValues] = useState(taskInitialValues(props.editTask));
23
+ const valuesRef = useRef(values);
24
+ const [focusIndex, setFocusIndex] = useState(0);
25
+ const [error, setError] = useState("");
26
+ const [isSubmitting, setIsSubmitting] = useState(false);
27
+ const [allTasks, setAllTasks] = useState([]);
28
+ const { width: termWidth } = useTerminalDimensions();
29
+ const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
30
+ const saveIndex = taskFields.length;
31
+ const cancelIndex = taskFields.length + 1;
32
+ function updateValues(next) {
33
+ valuesRef.current = next;
34
+ setValues(next);
35
+ }
36
+ useState(() => {
37
+ void listTasks().then(setAllTasks).catch(() => { });
38
+ });
39
+ useKeyboard((key) => {
40
+ if (key.name === "tab") {
41
+ setFocusIndex((i) => {
42
+ const next = key.shift ? i - 1 : i + 1;
43
+ return Math.max(0, Math.min(cancelIndex, next));
44
+ });
45
+ return;
46
+ }
47
+ if (key.name === "left" || key.name === "right") {
48
+ if (focusIndex === saveIndex) {
49
+ setFocusIndex(key.name === "right" ? cancelIndex : taskFields.length - 1);
50
+ return;
51
+ }
52
+ if (focusIndex === cancelIndex) {
53
+ setFocusIndex(key.name === "right" ? 0 : saveIndex);
54
+ return;
55
+ }
56
+ if (key.name === "right") {
57
+ if (focusIndex + 1 < taskFields.length) {
58
+ setFocusIndex((i) => i + 1);
59
+ }
60
+ else {
61
+ setFocusIndex(saveIndex);
62
+ }
63
+ return;
64
+ }
65
+ if (key.name === "left") {
66
+ if (focusIndex > 0) {
67
+ setFocusIndex((i) => i - 1);
68
+ }
69
+ else {
70
+ setFocusIndex(cancelIndex);
71
+ }
72
+ return;
73
+ }
74
+ }
75
+ if (key.name === "return" || key.name === "enter") {
76
+ if (focusIndex === saveIndex) {
77
+ void submit(valuesRef.current);
78
+ }
79
+ else if (focusIndex === cancelIndex) {
80
+ props.onCancel();
81
+ }
82
+ }
83
+ });
84
+ async function submit(current) {
85
+ if (isSubmitting)
86
+ return;
87
+ try {
88
+ setIsSubmitting(true);
89
+ setError("");
90
+ const cmdLine = current.command.trim();
91
+ if (!cmdLine) {
92
+ setError(t("errors.commandEmpty"));
93
+ return;
94
+ }
95
+ const tokens = cmdLine.split(/\s+/);
96
+ const command = tokens[0] ?? "";
97
+ const commandArgs = tokens.slice(1);
98
+ const onSuccessTaskId = current.onSuccessTaskId || null;
99
+ const onFailureTaskId = current.onFailureTaskId || null;
100
+ if (props.mode === "edit" && props.editTask) {
101
+ await updateTask(props.editTask.id, {
102
+ name: current.name.trim() || command,
103
+ command,
104
+ commandArgs,
105
+ cwd: props.editTask.cwd,
106
+ onSuccessTaskId,
107
+ onFailureTaskId,
108
+ });
109
+ props.onDone(true, props.editTask.id);
110
+ }
111
+ else {
112
+ const id = crypto.randomUUID().slice(0, 8);
113
+ await createTask({
114
+ id,
115
+ name: current.name.trim() || command,
116
+ command,
117
+ commandArgs,
118
+ cwd: "",
119
+ onSuccessTaskId,
120
+ onFailureTaskId,
121
+ });
122
+ props.onDone(false, id);
123
+ }
124
+ }
125
+ catch (e) {
126
+ setError(e instanceof Error ? e.message : String(e));
127
+ }
128
+ finally {
129
+ setIsSubmitting(false);
130
+ }
131
+ }
132
+ const labels = {
133
+ name: t("board.taskLabelName"),
134
+ command: t("board.taskLabelCommand"),
135
+ onSuccessTaskId: t("board.taskLabelOnSuccess"),
136
+ onFailureTaskId: t("board.taskLabelOnFailure"),
137
+ };
138
+ const hints = {
139
+ name: t("board.taskHintName"),
140
+ command: t("board.hintCommand"),
141
+ onSuccessTaskId: t("board.taskHintOnSuccess"),
142
+ onFailureTaskId: t("board.taskHintOnFailure"),
143
+ };
144
+ const chainOptions = [
145
+ { name: t("board.taskNone"), description: "", value: "" },
146
+ ...allTasks
147
+ .filter((t) => t.id !== props.editTask?.id)
148
+ .map((t) => ({ name: `${t.id} ${t.name}`, description: "", value: t.id })),
149
+ ];
150
+ const title = props.mode === "edit" ? t("board.taskEditTitle") : t("board.taskCreateTitle");
151
+ return (_jsxs("box", { title: title, border: true, style: { flexDirection: "column", flexGrow: 1, padding: 1, backgroundColor: "#0b0b0b" }, children: [_jsx("box", { style: { flexDirection: "column", flexGrow: 1 }, children: Array.from({ length: Math.ceil(taskFields.length / 2) }, (_, row) => {
152
+ const leftField = taskFields[row * 2];
153
+ const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
154
+ return (_jsxs("box", { style: { flexDirection: "row", marginBottom: 1 }, children: [_jsx(TaskFormRow, { field: leftField, index: row * 2, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%", paddingRight: 1 } }), rightField ? (_jsx(TaskFormRow, { field: rightField, index: row * 2 + 1, focusIndex: focusIndex, values: values, valuesRef: valuesRef, updateValues: updateValues, setFocusIndex: setFocusIndex, submit: submit, labels: labels, hints: hints, chainOptions: chainOptions, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
155
+ }) }), _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] }));
156
+ }
157
+ function TaskFormRow(props) {
158
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, style } = props;
159
+ const isFocused = focusIndex === index;
160
+ const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
161
+ const selectOpts = isSelect ? chainOptions : [];
162
+ const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
163
+ return (_jsxs("box", { style: { flexDirection: "column", ...style }, children: [_jsx("text", { fg: isFocused ? "#38bdf8" : "#e5e7eb", children: labels[field] }), _jsx("text", { fg: "#6b7280", children: hints[field] }), isSelect ? (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: Math.min(selectOpts.length + 2, 6), backgroundColor: "#0b0b0b" }, children: _jsx("select", { focused: isFocused, options: selectOpts, selectedIndex: Math.max(0, selectedIdx), showDescription: false, style: { flexGrow: 1 }, onChange: (_i, option) => updateValues({ ...valuesRef.current, [field]: option?.value ?? "" }) }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
164
+ if (index < taskFields.length - 1) {
165
+ setFocusIndex(index + 1);
166
+ }
167
+ else {
168
+ void submit(valuesRef.current);
169
+ }
170
+ } }) }))] }));
171
+ }
172
+ function HoverButton(props) {
173
+ const { isHovered, hoverProps } = useHoverState();
174
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
175
+ const borderColor = props.selected ? "#38bdf8" : undefined;
176
+ 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 }) }) }));
177
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ export function Timeline(props) {
4
+ return (_jsx("scrollbox", { title: t("board.timelineTitle"), border: true, style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, stickyScroll: true, stickyStart: "bottom", children: props.logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.timelineEmpty") })) : (props.logLines.map((line, index) => _jsx("text", { children: line }, index))) }));
5
+ }
@@ -0,0 +1,117 @@
1
+ import { sendRequest, streamRequest } from "../client/ipc.js";
2
+ import { t } from "../i18n/index.js";
3
+ import { LOG_TAIL_DEFAULT } from "../config/constants.js";
4
+ function expectOk(message, type) {
5
+ if (type !== "ok") {
6
+ throw new Error(message);
7
+ }
8
+ }
9
+ export async function listLoops() {
10
+ const response = await sendRequest({ type: "list" });
11
+ if (response.type !== "ok") {
12
+ throw new Error(response.message);
13
+ }
14
+ return response.data;
15
+ }
16
+ export async function pauseLoop(id) {
17
+ const response = await sendRequest({ type: "pause", payload: { id } });
18
+ expectOk(response.message ?? t("errors.pauseFailed"), response.type);
19
+ }
20
+ export async function resumeLoop(id) {
21
+ const response = await sendRequest({ type: "resume", payload: { id } });
22
+ expectOk(response.message ?? t("errors.resumeFailed"), response.type);
23
+ }
24
+ export async function stopLoop(id) {
25
+ const response = await sendRequest({ type: "stop-loop", payload: { id } });
26
+ expectOk(response.message ?? "Stop failed", response.type);
27
+ }
28
+ export async function playLoop(id) {
29
+ const response = await sendRequest({ type: "play-loop", payload: { id } });
30
+ expectOk(response.message ?? "Play failed", response.type);
31
+ }
32
+ export async function triggerLoop(id) {
33
+ const response = await sendRequest({ type: "trigger", payload: { id } });
34
+ expectOk(response.message ?? t("errors.forceRunFailed"), response.type);
35
+ }
36
+ export async function deleteLoop(id) {
37
+ const response = await sendRequest({ type: "delete", payload: { id } });
38
+ expectOk(response.message ?? t("errors.deleteFailed"), response.type);
39
+ }
40
+ export async function createLoop(options, intervalHuman) {
41
+ const response = await sendRequest({
42
+ type: "start",
43
+ payload: { ...options, intervalHuman },
44
+ });
45
+ if (response.type !== "ok") {
46
+ throw new Error(response.message);
47
+ }
48
+ return response.data.id;
49
+ }
50
+ export async function updateLoop(id, options, intervalHuman) {
51
+ const response = await sendRequest({
52
+ type: "update",
53
+ payload: { id, ...options, intervalHuman },
54
+ });
55
+ if (response.type !== "ok") {
56
+ throw new Error(response.message);
57
+ }
58
+ return response.data.id;
59
+ }
60
+ export function streamLogs(id, onLine, onError) {
61
+ return streamRequest({ type: "logs", payload: { id, follow: true, tail: LOG_TAIL_DEFAULT } }, onLine, () => { }, onError);
62
+ }
63
+ export async function fetchRunLog(id, runNumber) {
64
+ const response = await sendRequest({ type: "run-log", payload: { id, runNumber } });
65
+ if (response.type !== "ok") {
66
+ throw new Error(response.message ?? "Failed to fetch run log");
67
+ }
68
+ return response.data ?? "";
69
+ }
70
+ export function streamRunLog(id, runNumber, onLine, onEnd, onError) {
71
+ return streamRequest({ type: "run-log-stream", payload: { id, runNumber } }, onLine, onEnd, onError);
72
+ }
73
+ export async function listTasks() {
74
+ const response = await sendRequest({ type: "task-list" });
75
+ if (response.type !== "ok") {
76
+ throw new Error(response.message);
77
+ }
78
+ return response.data;
79
+ }
80
+ export async function createTask(payload) {
81
+ const response = await sendRequest({ type: "task-create", payload });
82
+ if (response.type !== "ok") {
83
+ throw new Error(response.message);
84
+ }
85
+ return response.data;
86
+ }
87
+ export async function updateTask(id, payload) {
88
+ const response = await sendRequest({ type: "task-update", payload: { id, ...payload } });
89
+ if (response.type !== "ok") {
90
+ throw new Error(response.message);
91
+ }
92
+ return response.data;
93
+ }
94
+ export async function deleteTask(id) {
95
+ const response = await sendRequest({ type: "task-delete", payload: { id } });
96
+ expectOk(response.message ?? "Task delete failed", response.type);
97
+ }
98
+ export async function listProjects() {
99
+ const response = await sendRequest({ type: "project-list" });
100
+ if (response.type !== "ok")
101
+ throw new Error(response.message);
102
+ return response.data;
103
+ }
104
+ export async function createProject(name, color) {
105
+ const response = await sendRequest({ type: "project-create", payload: { name, color } });
106
+ if (response.type !== "ok")
107
+ throw new Error(response.message);
108
+ return response.data;
109
+ }
110
+ export async function updateProject(id, name, color) {
111
+ const response = await sendRequest({ type: "project-update", payload: { id, name, color } });
112
+ expectOk(response.message ?? t("project.error.updateFailed"), response.type);
113
+ }
114
+ export async function deleteProject(id) {
115
+ const response = await sendRequest({ type: "project-delete", payload: { id } });
116
+ expectOk(response.message ?? t("project.error.deleteFailed"), response.type);
117
+ }
@@ -0,0 +1,102 @@
1
+ import { t } from "../i18n/index.js";
2
+ export function quoteArg(arg) {
3
+ return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
4
+ }
5
+ export function commandLine(command, args) {
6
+ return [command, ...args.map(quoteArg)].join(" ").trim();
7
+ }
8
+ export function formatCmd(command, args, max = 24) {
9
+ const full = commandLine(command, args);
10
+ return full.length > max ? full.slice(0, max - 3) + "..." : full;
11
+ }
12
+ export function describeLoop(loop) {
13
+ return loop.description?.trim() || commandLine(loop.command, loop.commandArgs);
14
+ }
15
+ export function truncate(text, max) {
16
+ return text.length > max ? text.slice(0, max - 3) + "..." : text;
17
+ }
18
+ export function timeAgo(iso) {
19
+ if (!iso)
20
+ return t("format.dash");
21
+ const diff = Date.now() - new Date(iso).getTime();
22
+ const secs = Math.floor(diff / 1000);
23
+ if (secs < 5)
24
+ return t("format.justNow");
25
+ if (secs < 60)
26
+ return t("format.secsAgo", { secs });
27
+ const mins = Math.floor(secs / 60);
28
+ if (mins < 60)
29
+ return t("format.minsAgo", { mins });
30
+ const hrs = Math.floor(mins / 60);
31
+ if (hrs < 24)
32
+ return t("format.hrsAgo", { hrs });
33
+ return t("format.daysAgo", { days: Math.floor(hrs / 24) });
34
+ }
35
+ export function timeUntil(iso) {
36
+ if (!iso)
37
+ return t("format.dash");
38
+ const diff = Math.max(0, new Date(iso).getTime() - Date.now());
39
+ const secs = Math.floor(diff / 1000);
40
+ if (secs < 5)
41
+ return t("format.justNow");
42
+ if (secs < 60)
43
+ return t("format.secsAhead", { secs });
44
+ const mins = Math.floor(secs / 60);
45
+ if (mins < 60)
46
+ return t("format.minsAhead", { mins });
47
+ const hrs = Math.floor(mins / 60);
48
+ if (hrs < 24)
49
+ return t("format.hrsAhead", { hrs });
50
+ return t("format.daysAhead", { days: Math.floor(hrs / 24) });
51
+ }
52
+ const STATUS_COLORS = {
53
+ running: "#4ade80",
54
+ waiting: "#38bdf8",
55
+ paused: "#facc15",
56
+ idle: "#fb923c",
57
+ stopped: "#f87171",
58
+ };
59
+ export function statusColor(status) {
60
+ return STATUS_COLORS[status] ?? "#ffffff";
61
+ }
62
+ export function timingLabel(loop) {
63
+ if (loop.status === "paused")
64
+ return t("format.timingPaused");
65
+ if (loop.status === "idle")
66
+ return t("format.timingIdle");
67
+ if (loop.nextRunAt)
68
+ return t("format.timingNext", { timeAgo: timeUntil(loop.nextRunAt) });
69
+ if (loop.lastRunAt)
70
+ return t("format.timingLast", { timeAgo: timeAgo(loop.lastRunAt) });
71
+ return t("format.timingNew");
72
+ }
73
+ export function statusLabel(status) {
74
+ return status === "waiting" ? "waiting" : status;
75
+ }
76
+ export function formatFileSize(bytes) {
77
+ if (bytes < 1024)
78
+ return `${bytes} B`;
79
+ if (bytes < 1024 * 1024)
80
+ return `${Math.round(bytes / 1024)} KB`;
81
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
82
+ }
83
+ export function formatRunDuration(ms) {
84
+ if (ms < 1000)
85
+ return `${ms}ms`;
86
+ if (ms < 60000)
87
+ return `${(ms / 1000).toFixed(1)}s`;
88
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
89
+ }
90
+ export function formatRunTime(iso) {
91
+ const d = new Date(iso);
92
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
93
+ }
94
+ export function sinceLabel(loop) {
95
+ const ts = loop.sessionStartedAt ?? loop.createdAt;
96
+ if (!ts)
97
+ return t("format.dash");
98
+ const d = new Date(ts);
99
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
100
+ const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
101
+ return `${date} ${time}`;
102
+ }