loop-task 1.4.4 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,8 @@ loop-task # open the board (requires Bun)
50
50
  loop-task start # start the daemon, restore persisted loops
51
51
  loop-task new 30m -- npm test # create a background loop
52
52
  loop-task run --now 10s -- echo hi # run a loop in the foreground
53
+ loop-task stop <id> # stop a frozen loop and kill its child process
54
+ loop-task restart # kill daemon + all loops, restart fresh
53
55
  ```
54
56
 
55
57
  Or run it directly:
@@ -137,6 +139,8 @@ Colors can be a name (`white`, `cyan`, `green`, `yellow`, `orange`, `pink`) or a
137
139
  | `loop-task new <interval> -- <command>` | Create a background loop (creates an inline task) |
138
140
  | `loop-task new <interval> --project <name> -- <command>` | Create a loop assigned to a project |
139
141
  | `loop-task run <interval> -- <command>` | Run a loop in the foreground |
142
+ | `loop-task stop <id>` | Stop a loop and interrupt its running child process |
143
+ | `loop-task restart` | Kill the daemon and all running loops, then restart fresh |
140
144
  | `loop-task project list` | List all projects |
141
145
  | `loop-task project new <name> [--color <color>]` | Create a project |
142
146
  | `loop-task project rename <id\|name> <new-name>` | Rename a project |
package/dist/board/App.js CHANGED
@@ -26,7 +26,7 @@ import { ProjectsPage } from "./components/ProjectsPage.js";
26
26
  import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
27
27
  import { useBreakpoint } from "./hooks/useBreakpoint.js";
28
28
  import { useRouter } from "./router.js";
29
- const BOARD_REFRESH_DELAY_MS = 150;
29
+ import { POLL_MS } from "../config/constants.js";
30
30
  const VIEW_TO_MODE = {
31
31
  create: "create",
32
32
  "task-create": "task",
@@ -117,14 +117,20 @@ export function App(props) {
117
117
  }
118
118
  catch { /* ignore */ }
119
119
  }
120
- useState(() => { void refreshTasks(); });
121
120
  async function refreshProjects() {
122
121
  try {
123
122
  setProjects(await listProjects());
124
123
  }
125
124
  catch { /* ignore */ }
126
125
  }
127
- useState(() => { void refreshProjects(); });
126
+ useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
127
+ useEffect(() => {
128
+ const timer = setInterval(() => {
129
+ void refreshTasks();
130
+ void refreshProjects();
131
+ }, POLL_MS);
132
+ return () => clearInterval(timer);
133
+ }, []);
128
134
  useEffect(() => {
129
135
  try {
130
136
  localStorage.setItem("loop-current-project", currentProjectId);
@@ -135,7 +141,7 @@ export function App(props) {
135
141
  return async () => {
136
142
  try {
137
143
  await action();
138
- setTimeout(() => { void refresh(); }, BOARD_REFRESH_DELAY_MS);
144
+ void refresh();
139
145
  pushToast("success", label);
140
146
  }
141
147
  catch (error) {
@@ -266,7 +272,7 @@ export function App(props) {
266
272
  setPendingTaskSelection(null);
267
273
  pop();
268
274
  pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
269
- setTimeout(() => { void refresh(); }, BOARD_REFRESH_DELAY_MS);
275
+ void refresh();
270
276
  };
271
277
  const onTaskDone = (updated, id) => {
272
278
  setEditTask(null);
@@ -309,8 +315,10 @@ export function App(props) {
309
315
  onOpenRunLog: handleOpenRunLog,
310
316
  refreshTasks,
311
317
  onViewTasks: () => { void refreshTasks(); push("task-list"); },
312
- onViewProjects: () => push("projects"),
318
+ onViewProjects: () => { void refreshProjects(); push("projects"); },
319
+ onViewLoops: () => replace("board"),
313
320
  onAddLoop: () => { setEditTarget(null); push("create"); },
321
+ onAddTask: () => { setEditTask(null); push("task-create"); },
314
322
  onSelectProject: () => setProjectsModalOpen(true),
315
323
  });
316
324
  useTaskKeybindings({
@@ -330,6 +338,9 @@ export function App(props) {
330
338
  onTaskAction: handleTaskAction,
331
339
  onCancel: cancelTaskList,
332
340
  onCreateTask: handleCreateTask,
341
+ onEnterHeader: (direction) => {
342
+ setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
343
+ },
333
344
  selectable: stack.includes("create") || stack.includes("task-edit"),
334
345
  });
335
346
  const counts = {
@@ -345,7 +356,9 @@ export function App(props) {
345
356
  }
346
357
  else {
347
358
  push("projects");
348
- } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; } })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
359
+ } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); setFocusedPanel("loops"); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: taskFocusedPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); setTaskFocusedPanel("tasks"); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: taskFocusedPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); setTaskFocusedPanel("tasks"); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskFocusedPanel === "actions", selectedAction: taskSelectedAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: (direction) => {
360
+ setFocusedPanel(direction === "right" ? "header-tasks" : "header-new");
361
+ } })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: focusedPanel === "loops", projects: projects, onSelect: (index) => { setSelectedIndex(index); setFocusedPanel("loops"); }, onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
349
362
  setEditTarget(loop);
350
363
  push("create");
351
364
  } } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => { setSelectedRunIndex(index); setFocusedPanel("runs"); }, onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
@@ -7,6 +7,7 @@ import { t } from "../../i18n/index.js";
7
7
  import { commandLine } from "../format.js";
8
8
  import { createLoop, updateLoop, listTasks } from "../daemon.js";
9
9
  import { useHoverState } from "../hooks/useHoverState.js";
10
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
10
11
  import { HOVER_BG } from "../../config/constants.js";
11
12
  export const TASK_MODE_INLINE = "inline";
12
13
  export const TASK_MODE_EXISTING = "existing";
@@ -47,6 +48,12 @@ export function CreateView(props) {
47
48
  const [error, setError] = useState("");
48
49
  const [isSubmitting, setIsSubmitting] = useState(false);
49
50
  const [selectedTaskName, setSelectedTaskName] = useState(props.selectedTaskName ?? null);
51
+ const inputRef = useRef(null);
52
+ useInputShortcuts(() => {
53
+ if (focusIndex >= saveIndex)
54
+ return null;
55
+ return inputRef.current;
56
+ });
50
57
  useState(() => {
51
58
  if (values.taskId && !selectedTaskName) {
52
59
  void listTasks().then((tasks) => {
@@ -271,7 +278,7 @@ export function CreateView(props) {
271
278
  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) => {
272
279
  const leftField = filteredFields[row * 2];
273
280
  const rightField = row * 2 + 1 < filteredFields.length ? filteredFields[row * 2 + 1] : null;
274
- 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));
281
+ 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, inputRef: inputRef, 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, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
275
282
  }) }), _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] }));
276
283
  }
277
284
  function HoverButton(props) {
@@ -281,7 +288,7 @@ function HoverButton(props) {
281
288
  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 }) }) }));
282
289
  }
283
290
  function FormRow(props) {
284
- const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, style } = props;
291
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, examples, taskModeOptions, runNowOptions, projectOptions, selectedTaskName, fields, onChooseTask, inputRef, style } = props;
285
292
  const { isHovered, hoverProps } = useHoverState();
286
293
  const isFocused = focusIndex === index;
287
294
  const isToggleField = field === "taskMode" || field === "runNow";
@@ -297,7 +304,7 @@ function FormRow(props) {
297
304
  : 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) => {
298
305
  const isActive = values.project === opt.value;
299
306
  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));
300
- }) })) : (_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: () => {
307
+ }) })) : (_jsx("box", { border: true, borderColor: isFocused ? "#38bdf8" : undefined, style: { height: 3, backgroundColor: "#0b0b0b" }, children: _jsx("input", { ref: inputRef, 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: () => {
301
308
  if (index < fields.length - 1) {
302
309
  setFocusIndex(index + 1);
303
310
  }
@@ -5,8 +5,8 @@ export function HelpModal(props) {
5
5
  const { view } = props;
6
6
  const { width } = useTerminalDimensions();
7
7
  const loopRows = [
8
- ["tab/←→", "switch panels"],
9
- ["↑/↓", "navigate list items"],
8
+ ["tab", "switch panels"],
9
+ ["up/down", "navigate list items"],
10
10
  ["enter", t("board.helpEdit")],
11
11
  ["e", "edit loop"],
12
12
  ["d/del", "delete loop"],
@@ -24,8 +24,8 @@ export function HelpModal(props) {
24
24
  ["esc", t("board.helpQuit")],
25
25
  ];
26
26
  const taskRows = [
27
- ["tab/←→", "switch panels"],
28
- ["↑/↓", "navigate tasks"],
27
+ ["tab", "switch panels"],
28
+ ["up/down", "navigate tasks"],
29
29
  ["enter", "focus actions panel"],
30
30
  ["e", "edit task"],
31
31
  ["d/del", "delete task"],
@@ -36,8 +36,8 @@ export function HelpModal(props) {
36
36
  ["esc", t("board.helpQuit")],
37
37
  ];
38
38
  const projectRows = [
39
- ["tab/←→", "switch panel (list/actions)"],
40
- ["↑/↓", "navigate projects"],
39
+ ["tab", "switch panel (list/actions)"],
40
+ ["up/down", "navigate projects"],
41
41
  ["enter", "focus actions"],
42
42
  ["n", "new project"],
43
43
  ["e", "edit project"],
@@ -6,12 +6,31 @@ import { formatRunDuration, formatRunTime } from "../format.js";
6
6
  import { streamRunLog } from "../daemon.js";
7
7
  import { LOG_LINES_MAX } from "../../config/constants.js";
8
8
  import { copyToClipboard } from "../../shared/clipboard.js";
9
+ function colorizeLine(line) {
10
+ if (/^\[Run #/.test(line)) {
11
+ return _jsx("text", { fg: "#38bdf8", children: _jsx("strong", { children: line }) });
12
+ }
13
+ if (/^--- Chain:/.test(line)) {
14
+ return _jsx("text", { fg: "#a78bfa", children: _jsx("strong", { children: line }) });
15
+ }
16
+ if (/^\[exit 0/.test(line)) {
17
+ return _jsx("text", { fg: "#4ade80", children: line });
18
+ }
19
+ if (/^\[exit /.test(line)) {
20
+ return _jsx("text", { fg: "#f87171", children: line });
21
+ }
22
+ if (/^\[error\]/.test(line)) {
23
+ return _jsx("text", { fg: "#f87171", children: line });
24
+ }
25
+ return _jsx("text", { fg: "#e5e7eb", children: line });
26
+ }
9
27
  export function LogModal(props) {
10
28
  const { loopId, run, logLines: staticLines, loading } = props;
11
29
  const { width, height } = useTerminalDimensions();
12
30
  const isRunning = run.status === "running";
13
31
  const [streamLines, setStreamLines] = useState([]);
14
32
  const socketRef = useRef(null);
33
+ const scrollRef = useRef(null);
15
34
  useEffect(() => {
16
35
  if (!isRunning || !loopId)
17
36
  return;
@@ -28,13 +47,18 @@ export function LogModal(props) {
28
47
  }
29
48
  };
30
49
  }, [isRunning, loopId, run.runNumber]);
50
+ const logLines = isRunning ? streamLines : staticLines;
51
+ useEffect(() => {
52
+ if (logLines.length > 0) {
53
+ scrollRef.current?.scrollChildIntoView(`log-line-${logLines.length - 1}`);
54
+ }
55
+ }, [logLines]);
31
56
  useKeyboard((key) => {
32
57
  if (key.ctrl && key.name === "c") {
33
- const all = (isRunning ? streamLines : staticLines).join("\n");
58
+ const all = logLines.join("\n");
34
59
  copyToClipboard(all);
35
60
  }
36
61
  });
37
- const logLines = isRunning ? streamLines : staticLines;
38
62
  const success = isRunning ? true : run.exitCode === 0;
39
63
  const icon = isRunning ? "⟳" : success ? "✓" : "✗";
40
64
  const iconColor = isRunning ? "#facc15" : success ? "#4ade80" : "#f87171";
@@ -54,5 +78,5 @@ export function LogModal(props) {
54
78
  width: Math.min(width - 4, 100),
55
79
  height: Math.min(height - 4, 30),
56
80
  backgroundColor: "#111827",
57
- }, children: [_jsx("scrollbox", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => _jsx("text", { children: line }, i))) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", backgroundColor: "#111827" }, children: [_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), " ", isRunning ? (_jsx("span", { fg: "#facc15", children: t("board.logModalRunning") })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#9ca3af", children: formatRunDuration(run.duration) }), " ", _jsxs("span", { fg: success ? "#4ade80" : "#f87171", children: ["exit ", run.exitCode] })] }))] }), _jsx("text", { fg: "#6b7280", children: t("board.logModalEscClose") })] })] }) }));
81
+ }, children: [_jsx("scrollbox", { ref: scrollRef, style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: loading && !isRunning ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalLoading") })) : logLines.length === 0 ? (_jsx("text", { fg: "#9ca3af", children: t("board.logModalEmpty") })) : (logLines.map((line, i) => (_jsx("box", { id: `log-line-${i}`, children: colorizeLine(line) }, i)))) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", backgroundColor: "#111827" }, children: [_jsxs("text", { children: [_jsx("span", { fg: iconColor, children: icon }), " ", isRunning ? (_jsx("span", { fg: "#facc15", children: t("board.logModalRunning") })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: "#9ca3af", children: formatRunDuration(run.duration) }), " ", _jsxs("span", { fg: success ? "#4ade80" : "#f87171", children: ["exit ", run.exitCode] })] }))] }), _jsx("text", { fg: "#6b7280", children: t("board.logModalEscClose") })] })] }) }));
58
82
  }
@@ -26,9 +26,9 @@ export function Navigator(props) {
26
26
  const bulletW = 2;
27
27
  const nonVar = 2 + 1 + statusW + 1 + 1 + 1 + exitW + 1 + 1 + runsW + 1 + skpW;
28
28
  const avail = Math.floor(panelWidth) - nonVar - bulletW;
29
- const descW = Math.min(22, Math.max(6, Math.round(avail * 0.30)));
30
- const sinceW = Math.min(14, Math.max(8, Math.round(avail * 0.35)));
31
- const timingW = Math.max(6, avail - descW - sinceW);
29
+ const descW = Math.min(32, Math.max(10, Math.round(avail * 0.38)));
30
+ const timingW = Math.min(16, Math.max(6, Math.round(avail * 0.25)));
31
+ const sinceW = Math.max(8, avail - descW - timingW);
32
32
  const header = " " +
33
33
  fit("", bulletW) +
34
34
  fit(t("board.headerDescription"), descW) +
@@ -15,7 +15,7 @@ function ProjectActionButton(props) {
15
15
  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 }) }) }));
16
16
  }
17
17
  export function ProjectsPage(props) {
18
- const { projects, loops, onClose, onRefresh, onOpenCreate } = props;
18
+ const { projects, loops, onClose, onRefresh, onOpenCreate, onEnterHeader } = props;
19
19
  const [selectedIndex, setSelectedIndex] = useState(0);
20
20
  const [subModal, setSubModal] = useState("none");
21
21
  const [focusedPanel, setFocusedPanel] = useState("list");
@@ -52,7 +52,18 @@ export function ProjectsPage(props) {
52
52
  key.preventDefault();
53
53
  return;
54
54
  }
55
- if (key.name === "left" || key.name === "right" || key.name === "tab") {
55
+ if (key.name === "tab") {
56
+ const direction = key.shift ? "left" : "right";
57
+ if (focusedPanel === "list" && direction === "left") {
58
+ onEnterHeader?.("left");
59
+ key.preventDefault();
60
+ return;
61
+ }
62
+ if (focusedPanel === "actions" && direction === "right") {
63
+ onEnterHeader?.("right");
64
+ key.preventDefault();
65
+ return;
66
+ }
56
67
  setFocusedPanel((p) => (p === "list" ? "actions" : "list"));
57
68
  key.preventDefault();
58
69
  return;
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useRef } from "react";
2
3
  import { t } from "../../i18n/index.js";
4
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
3
5
  export function SearchBox(props) {
4
6
  const { query, searchActive, focused, onQueryChange, onActivate, onDismiss } = props;
5
- return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
7
+ const inputRef = useRef(null);
8
+ useInputShortcuts(() => searchActive ? inputRef.current : null);
9
+ return (_jsx("box", { title: t("board.searchTitle"), border: true, borderColor: focused ? "#38bdf8" : undefined, style: { width: "25%", height: 3, marginRight: 1, paddingLeft: 1, backgroundColor: focused ? "#1e2a4a" : "#0b0b0b" }, children: searchActive ? (_jsx("input", { ref: inputRef, focused: true, value: query, placeholder: t("board.searchPlaceholder"), onInput: (value) => onQueryChange(value), onSubmit: () => onDismiss() })) : (_jsx("text", { fg: query ? "#e5e7eb" : "#6b7280", onMouseDown: onActivate, children: query || t("board.searchEmpty") })) }));
6
10
  }
@@ -5,6 +5,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
5
  import { t } from "../../i18n/index.js";
6
6
  import { createTask, updateTask, listTasks } from "../daemon.js";
7
7
  import { useHoverState } from "../hooks/useHoverState.js";
8
+ import { useInputShortcuts } from "../hooks/useInputShortcuts.js";
8
9
  import { HOVER_BG } from "../../config/constants.js";
9
10
  const taskFields = ["name", "command", "onSuccessTaskId", "onFailureTaskId"];
10
11
  function taskInitialValues(task) {
@@ -25,8 +26,14 @@ export function TaskForm(props) {
25
26
  const [error, setError] = useState("");
26
27
  const [isSubmitting, setIsSubmitting] = useState(false);
27
28
  const [allTasks, setAllTasks] = useState([]);
29
+ const inputRef = useRef(null);
28
30
  const { width: termWidth } = useTerminalDimensions();
29
31
  const btnWidth = Math.max(10, Math.min(14, Math.floor(termWidth / 6)));
32
+ useInputShortcuts(() => {
33
+ if (focusIndex >= saveIndex)
34
+ return null;
35
+ return inputRef.current;
36
+ });
30
37
  const saveIndex = taskFields.length;
31
38
  const cancelIndex = taskFields.length + 1;
32
39
  function updateValues(next) {
@@ -140,16 +147,16 @@ export function TaskForm(props) {
140
147
  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) => {
141
148
  const leftField = taskFields[row * 2];
142
149
  const rightField = row * 2 + 1 < taskFields.length ? taskFields[row * 2 + 1] : null;
143
- 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));
150
+ 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, inputRef: inputRef, 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, inputRef: inputRef, style: { width: "50%" } })) : (_jsx("box", { style: { width: "50%" } }))] }, row));
144
151
  }) }), _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] }));
145
152
  }
146
153
  function TaskFormRow(props) {
147
- const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, style } = props;
154
+ const { field, index, focusIndex, values, valuesRef, updateValues, setFocusIndex, submit, labels, hints, chainOptions, inputRef, style } = props;
148
155
  const isFocused = focusIndex === index;
149
156
  const isSelect = field === "onSuccessTaskId" || field === "onFailureTaskId";
150
157
  const selectOpts = isSelect ? chainOptions : [];
151
158
  const selectedIdx = isSelect ? selectOpts.findIndex((o) => o.value === values[field]) : 0;
152
- 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: () => {
159
+ 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", { ref: inputRef, focused: isFocused, value: values[field], placeholder: field === "command" ? t("board.exampleCommand") : "", onInput: (value) => updateValues({ ...valuesRef.current, [field]: value }), onSubmit: () => {
153
160
  if (index < taskFields.length - 1) {
154
161
  setFocusIndex(index + 1);
155
162
  }
@@ -195,7 +195,7 @@ const BOARD_SHORTCUTS = {
195
195
  r: (p) => p.onSelectProject?.(),
196
196
  };
197
197
  export function useBoardKeybindings(params) {
198
- const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onAddLoop, onSelectProject, } = params;
198
+ const { confirm, confirmChoice, setConfirm, setConfirmChoice, helpOpen, setHelpOpen, searchActive, setSearchActive, view, push, pop, setEditTarget, setEditTask, selected, visibleCount, setSelectedIndex, setFilters, setSort, onQuit, destroyLogSocket, logModalRun, setLogModalRun, logModalLines, selectedRunIndex, setSelectedRunIndex, selectedRunCount, focusedPanel, setFocusedPanel, selectedAction, setSelectedAction, onAction, onOpenRunLog, refreshTasks, onViewTasks, onViewProjects, onViewLoops, onAddLoop, onAddTask, onSelectProject, } = params;
199
199
  useKeyboard((key) => {
200
200
  const name = key.name;
201
201
  if (confirm) {
@@ -230,6 +230,19 @@ export function useBoardKeybindings(params) {
230
230
  key.preventDefault();
231
231
  return;
232
232
  }
233
+ if (name === "up" || name === "k" || name === "down" || name === "j") {
234
+ const handler = panelHandlers["loops"];
235
+ if (handler) {
236
+ handler(name, {
237
+ setSearchActive, setFilters, setSort, setEditTarget, push,
238
+ setSelectedIndex, setSelectedRunIndex, setFocusedPanel, selectedRunCount, selected,
239
+ selectedRunIndex, setSelectedAction, selectedAction, onAction, onOpenRunLog,
240
+ refreshTasks, onViewTasks, onViewProjects, onAddLoop,
241
+ });
242
+ key.preventDefault();
243
+ }
244
+ return;
245
+ }
233
246
  return;
234
247
  }
235
248
  if (view !== "board" && name === "escape") {
@@ -237,11 +250,78 @@ export function useBoardKeybindings(params) {
237
250
  key.preventDefault();
238
251
  return;
239
252
  }
253
+ if (name === "tab" && view !== "board") {
254
+ const HEADER_PANELS = ["header-tasks", "header-projects", "header-new"];
255
+ const isHeader = HEADER_PANELS.includes(focusedPanel);
256
+ const direction = key.shift ? "left" : "right";
257
+ if (isHeader) {
258
+ const idx = HEADER_PANELS.indexOf(focusedPanel);
259
+ const nextIdx = direction === "right"
260
+ ? (idx + 1) % HEADER_PANELS.length
261
+ : (idx - 1 + HEADER_PANELS.length) % HEADER_PANELS.length;
262
+ setFocusedPanel(HEADER_PANELS[nextIdx]);
263
+ key.preventDefault();
264
+ return;
265
+ }
266
+ }
267
+ if (view !== "board" && (name === "return" || name === "enter")) {
268
+ if (view === "task-list") {
269
+ if (focusedPanel === "header-tasks") {
270
+ onViewProjects?.();
271
+ key.preventDefault();
272
+ return;
273
+ }
274
+ if (focusedPanel === "header-projects") {
275
+ onViewLoops?.();
276
+ key.preventDefault();
277
+ return;
278
+ }
279
+ if (focusedPanel === "header-new") {
280
+ onAddTask?.();
281
+ key.preventDefault();
282
+ return;
283
+ }
284
+ }
285
+ else if (view === "projects") {
286
+ if (focusedPanel === "header-tasks") {
287
+ onViewLoops?.();
288
+ key.preventDefault();
289
+ return;
290
+ }
291
+ if (focusedPanel === "header-projects") {
292
+ onViewTasks?.();
293
+ key.preventDefault();
294
+ return;
295
+ }
296
+ if (focusedPanel === "header-new") {
297
+ onViewProjects?.();
298
+ key.preventDefault();
299
+ return;
300
+ }
301
+ }
302
+ else if (view === "create" || view === "task-create" || view === "task-edit") {
303
+ if (focusedPanel === "header-tasks") {
304
+ onViewProjects?.();
305
+ key.preventDefault();
306
+ return;
307
+ }
308
+ if (focusedPanel === "header-projects") {
309
+ onViewTasks?.();
310
+ key.preventDefault();
311
+ return;
312
+ }
313
+ if (focusedPanel === "header-new") {
314
+ onAddLoop?.();
315
+ key.preventDefault();
316
+ return;
317
+ }
318
+ }
319
+ }
240
320
  if (view !== "board")
241
321
  return;
242
- if (name === "left" || name === "right" || name === "tab") {
243
- const direction = name === "left" || (name === "tab" && key.shift) ? "left" : "right";
244
- if (focusedPanel === "actions" && name !== "tab") {
322
+ if (name === "tab") {
323
+ const direction = key.shift ? "left" : "right";
324
+ if (focusedPanel === "actions") {
245
325
  const actionCount = selected ? getActionCount(selected.status) : 0;
246
326
  if (direction === "left" && selectedAction === 0) {
247
327
  setFocusedPanel((p) => nextPanel(p, "left"));
@@ -0,0 +1,61 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { copyToClipboard, readFromClipboard } from "../../shared/clipboard.js";
3
+ export function useInputShortcuts(getActiveInput) {
4
+ useKeyboard((key) => {
5
+ if (!key.ctrl)
6
+ return;
7
+ const input = getActiveInput();
8
+ if (!input)
9
+ return;
10
+ const name = key.name;
11
+ if (name === "c") {
12
+ try {
13
+ const text = input.getSelectedText();
14
+ if (text) {
15
+ copyToClipboard(text);
16
+ }
17
+ }
18
+ catch (e) {
19
+ console.error(`[loop-task] copy failed: ${e}`);
20
+ }
21
+ key.preventDefault();
22
+ key.stopPropagation();
23
+ return;
24
+ }
25
+ if (name === "x") {
26
+ try {
27
+ const text = input.getSelectedText();
28
+ if (text) {
29
+ copyToClipboard(text);
30
+ input.deleteSelection();
31
+ }
32
+ }
33
+ catch (e) {
34
+ console.error(`[loop-task] cut failed: ${e}`);
35
+ }
36
+ key.preventDefault();
37
+ key.stopPropagation();
38
+ return;
39
+ }
40
+ if (name === "v") {
41
+ try {
42
+ const text = readFromClipboard();
43
+ if (text) {
44
+ input.insertText(text);
45
+ }
46
+ }
47
+ catch (e) {
48
+ console.error(`[loop-task] paste failed: ${e}`);
49
+ }
50
+ key.preventDefault();
51
+ key.stopPropagation();
52
+ return;
53
+ }
54
+ if (name === "a") {
55
+ input.selectAll();
56
+ key.preventDefault();
57
+ key.stopPropagation();
58
+ return;
59
+ }
60
+ });
61
+ }
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
2
  import { nextTaskPanel } from "../components/TaskBrowser.js";
3
3
  export function useTaskKeybindings(params) {
4
- const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, selectable = true, } = params;
4
+ const { confirm, view, tasks, taskSelectedIndex, setTaskSelectedIndex, taskSelectedAction, setTaskSelectedAction, taskFocusedPanel, setTaskFocusedPanel, taskSearchActive, setTaskSearchActive, taskQuery, setTaskQuery, onTaskAction, onCancel, onCreateTask, onEnterHeader, selectable = true, } = params;
5
5
  const taskActions = selectable
6
6
  ? ["select", "edit", "delete"]
7
7
  : ["edit", "delete"];
@@ -19,6 +19,16 @@ export function useTaskKeybindings(params) {
19
19
  key.preventDefault();
20
20
  return;
21
21
  }
22
+ if (name === "up" || name === "k") {
23
+ setTaskSelectedIndex((i) => Math.max(0, i - 1));
24
+ key.preventDefault();
25
+ return;
26
+ }
27
+ if (name === "down" || name === "j") {
28
+ setTaskSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
29
+ key.preventDefault();
30
+ return;
31
+ }
22
32
  return;
23
33
  }
24
34
  if (name === "escape") {
@@ -36,14 +46,16 @@ export function useTaskKeybindings(params) {
36
46
  key.preventDefault();
37
47
  return;
38
48
  }
39
- if (name === "tab" || name === "left" || name === "right") {
40
- const direction = name === "left" || (name === "tab" && key.shift) ? "left" : "right";
41
- if (taskFocusedPanel === "actions" && name !== "tab") {
49
+ if (name === "tab") {
50
+ const direction = key.shift ? "left" : "right";
51
+ if (taskFocusedPanel === "actions") {
42
52
  if (direction === "left" && taskSelectedAction === 0) {
43
53
  setTaskFocusedPanel((p) => nextTaskPanel(p, "left"));
44
54
  }
45
55
  else if (direction === "right" && taskSelectedAction === taskActionCount - 1) {
46
- setTaskFocusedPanel((p) => nextTaskPanel(p, "right"));
56
+ onEnterHeader("right");
57
+ key.preventDefault();
58
+ return;
47
59
  }
48
60
  else {
49
61
  setTaskSelectedAction((i) => direction === "right"
@@ -51,6 +63,11 @@ export function useTaskKeybindings(params) {
51
63
  : Math.max(0, i - 1));
52
64
  }
53
65
  }
66
+ else if (taskFocusedPanel === "search" && direction === "left") {
67
+ onEnterHeader("left");
68
+ key.preventDefault();
69
+ return;
70
+ }
54
71
  else {
55
72
  setTaskFocusedPanel((p) => nextTaskPanel(p, direction));
56
73
  }
@@ -46,7 +46,7 @@ export function ToastStack(props) {
46
46
  }
47
47
  return (_jsx("box", { style: {
48
48
  position: "absolute",
49
- bottom: 1,
49
+ bottom: 3,
50
50
  right: 2,
51
51
  flexDirection: "column",
52
52
  alignItems: "flex-end",
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { Logger } from "./logger.js";
5
5
  import { runLoop } from "./core/foreground-loop.js";
6
6
  import { buildLoopOptions } from "./loop-config.js";
7
+ import { parseDuration } from "./duration.js";
7
8
  import { startLoop } from "./client/commands.js";
8
9
  import { listProjectsCli, createProjectCli, renameProjectCli, setProjectColorCli, deleteProjectCli, resolveProjectId, } from "./client/commands.js";
9
10
  import { t } from "./i18n/index.js";
@@ -22,6 +23,49 @@ program
22
23
  ensureDaemon();
23
24
  console.log(t("cli.daemonStarted"));
24
25
  });
26
+ program
27
+ .command("stop")
28
+ .description("Stop a running loop and interrupt its current execution")
29
+ .argument("<id>", "Loop ID")
30
+ .action(async (id) => {
31
+ try {
32
+ const { sendRequest } = await import("./client/ipc.js");
33
+ const res = await sendRequest({ type: "stop-loop", payload: { id } });
34
+ if (res.type === "ok" && res.data) {
35
+ console.log(`Stopped loop ${id}`);
36
+ }
37
+ else if (res.type === "error") {
38
+ console.error(res.message);
39
+ process.exit(1);
40
+ }
41
+ else {
42
+ console.error(`Loop ${id} not found`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ console.error(t("cli.error", { message }));
49
+ process.exit(1);
50
+ }
51
+ });
52
+ program
53
+ .command("restart")
54
+ .description("Kill the daemon and all running loops, then restart fresh")
55
+ .action(async () => {
56
+ const { stopDaemon, ensureDaemon } = await import("./daemon/spawner.js");
57
+ const { readDaemonPid, removeDaemonPid, removeDaemonSignature } = await import("./daemon/state.js");
58
+ const pid = readDaemonPid();
59
+ if (pid !== null) {
60
+ console.log("Stopping daemon...");
61
+ stopDaemon(pid);
62
+ removeDaemonPid();
63
+ removeDaemonSignature();
64
+ }
65
+ console.log("Starting fresh daemon...");
66
+ ensureDaemon();
67
+ console.log("Daemon restarted.");
68
+ });
25
69
  program
26
70
  .command("new")
27
71
  .description(t("cli.newDescription"))
@@ -32,17 +76,20 @@ program
32
76
  .option("--verbose", t("cli.optVerbose"), false)
33
77
  .option("--cwd <dir>", t("cli.optCwd"))
34
78
  .option("--project <name>", t("cli.optProject"))
79
+ .option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
35
80
  .action(async (intervalStr, cmdArgs, opts) => {
36
81
  try {
37
82
  const projectId = opts.project
38
83
  ? await resolveProjectId(opts.project)
39
84
  : undefined;
85
+ const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
40
86
  const built = buildLoopOptions(intervalStr, {
41
87
  ...opts,
42
88
  command: cmdArgs[0],
43
89
  commandArgs: cmdArgs.slice(1),
44
90
  cwd: opts.cwd ?? process.cwd(),
45
91
  projectId,
92
+ offset: offsetMs,
46
93
  });
47
94
  await startLoop(built.options, built.intervalHuman);
48
95
  }
@@ -5,6 +5,8 @@ import { sleep } from "../shared/sleep.js";
5
5
  import { SLEEP_CHUNK_MS } from "../config/constants.js";
6
6
  import { executeCommand } from "./command-runner.js";
7
7
  import { rotateLogIfNeeded } from "./log-rotator.js";
8
+ import { computePhase, alignToPhase } from "./scheduling.js";
9
+ import { t } from "../i18n/index.js";
8
10
  export class LoopController extends EventEmitter {
9
11
  abortController;
10
12
  runAbortController = null;
@@ -115,7 +117,11 @@ export class LoopController extends EventEmitter {
115
117
  this._resetSchedule = true;
116
118
  this._paused = false;
117
119
  this._status = "waiting";
118
- this.nextRunAt = new Date(Date.now() + this.options.interval).toISOString();
120
+ const phase = this.options.offset !== null
121
+ ? this.options.offset
122
+ : computePhase(this.id, this.options.interval);
123
+ const delay = alignToPhase(Date.now(), this.options.interval, phase);
124
+ this.nextRunAt = new Date(Date.now() + delay).toISOString();
119
125
  if (this.resumeResolve) {
120
126
  this.resumeResolve();
121
127
  this.resumeResolve = null;
@@ -273,7 +279,14 @@ export class LoopController extends EventEmitter {
273
279
  }
274
280
  }
275
281
  else if (isFirstRun && !this.options.immediate) {
276
- const completed = await this.waitForDelay(this.options.interval, signal);
282
+ const phase = this.options.offset !== null
283
+ ? this.options.offset
284
+ : computePhase(this.id, this.options.interval);
285
+ const delay = alignToPhase(Date.now(), this.options.interval, phase);
286
+ if (delay > 0) {
287
+ this.nextRunAt = new Date(Date.now() + delay).toISOString();
288
+ }
289
+ const completed = await this.waitForDelay(delay, signal);
277
290
  if (!completed) {
278
291
  break;
279
292
  }
@@ -344,6 +357,9 @@ export class LoopController extends EventEmitter {
344
357
  const chainTask = this.taskResolver(currentTargetId);
345
358
  if (!chainTask)
346
359
  break;
360
+ if (this.logStream) {
361
+ this.logStream.write(t("loop.chainHeader", { name: chainTask.name }));
362
+ }
347
363
  const chainStartedAt = new Date().toISOString();
348
364
  const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
349
365
  this.runHistory.push({
@@ -0,0 +1,12 @@
1
+ export function computePhase(loopId, intervalMs) {
2
+ let hash = 0;
3
+ for (let i = 0; i < loopId.length; i++) {
4
+ hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
5
+ }
6
+ return Math.abs(hash) % intervalMs;
7
+ }
8
+ export function alignToPhase(now, intervalMs, phaseMs) {
9
+ const elapsed = now % intervalMs;
10
+ const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
11
+ return delay;
12
+ }
@@ -44,6 +44,7 @@ export class LoopManager {
44
44
  verbose: meta.verbose,
45
45
  description: meta.description ?? "",
46
46
  projectId: meta.projectId ?? "default",
47
+ offset: meta.offset ?? null,
47
48
  };
48
49
  const logPath = getLogPath(meta.id);
49
50
  const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
@@ -161,10 +162,19 @@ export class LoopManager {
161
162
  const entry = this.loops.get(id);
162
163
  if (!entry)
163
164
  return false;
164
- entry.controller.stopLoop();
165
+ entry.controller.stopLoop(true);
165
166
  this.persist(id, entry.controller, entry.options, entry.intervalHuman);
166
167
  return true;
167
168
  }
169
+ stopAllLoops() {
170
+ let count = 0;
171
+ for (const [id, entry] of this.loops) {
172
+ entry.controller.stopLoop(true);
173
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
174
+ count++;
175
+ }
176
+ return count;
177
+ }
168
178
  playLoop(id) {
169
179
  const entry = this.loops.get(id);
170
180
  if (!entry)
@@ -255,6 +265,7 @@ export class LoopManager {
255
265
  remainingDelayMs: runtime.remainingDelayMs,
256
266
  pid: process.pid,
257
267
  projectId: options.projectId ?? "default",
268
+ offset: options.offset,
258
269
  };
259
270
  }
260
271
  }
@@ -112,6 +112,11 @@ export class IpcServer {
112
112
  this.respondOk(socket, this.manager.stopLoop(request.payload.id), request.payload.id);
113
113
  break;
114
114
  }
115
+ case "stop-all": {
116
+ const count = this.manager.stopAllLoops();
117
+ send(socket, { type: "ok", data: count });
118
+ break;
119
+ }
115
120
  case "play-loop": {
116
121
  if (this.manager.isMaxRunsBlocked(request.payload.id)) {
117
122
  send(socket, { type: "error", message: t("errors.maxRunsReached") });
@@ -257,8 +262,10 @@ export class IpcServer {
257
262
  send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
258
263
  return;
259
264
  }
260
- const record = meta.runHistory.find((r) => r.runNumber === runNumber);
261
- if (!record) {
265
+ const records = meta.runHistory
266
+ .filter((r) => r.runNumber === runNumber)
267
+ .sort((a, b) => a.logOffset - b.logOffset);
268
+ if (records.length === 0) {
262
269
  send(socket, { type: "ok", data: "" });
263
270
  return;
264
271
  }
@@ -267,12 +274,12 @@ export class IpcServer {
267
274
  send(socket, { type: "ok", data: "" });
268
275
  return;
269
276
  }
270
- const offset = record.logOffset;
277
+ const firstOffset = records[0].logOffset;
271
278
  const stat = fs.statSync(logPath);
272
- if (stat.size > offset) {
279
+ if (stat.size > firstOffset) {
273
280
  const fd = fs.openSync(logPath, "r");
274
- const buf = Buffer.alloc(stat.size - offset);
275
- fs.readSync(fd, buf, 0, buf.length, offset);
281
+ const buf = Buffer.alloc(stat.size - firstOffset);
282
+ fs.readSync(fd, buf, 0, buf.length, firstOffset);
276
283
  fs.closeSync(fd);
277
284
  for (const line of buf.toString().split("\n")) {
278
285
  if (line) {
@@ -280,7 +287,8 @@ export class IpcServer {
280
287
  }
281
288
  }
282
289
  }
283
- if (record.status === "completed") {
290
+ const allCompleted = records.every((r) => r.status === "completed");
291
+ if (allCompleted) {
284
292
  send(socket, { type: "end" });
285
293
  return;
286
294
  }
@@ -16,7 +16,7 @@ function currentCodeSignature() {
16
16
  function blockingWait(ms) {
17
17
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
18
18
  }
19
- function stopDaemon(pid) {
19
+ export function stopDaemon(pid) {
20
20
  try {
21
21
  process.kill(pid, "SIGTERM");
22
22
  }
package/dist/entry.js CHANGED
File without changes
package/dist/i18n/en.json CHANGED
@@ -65,6 +65,7 @@
65
65
  "cli.projectAmbiguous": "Multiple projects named \"{name}\" - use the id instead",
66
66
  "cli.projectInvalidColor": "Invalid color \"{color}\". Valid names: {valid}, or a #rrggbb hex",
67
67
  "loop.runHeader": "\n[Run #{runNumber} - {timestamp}]\n",
68
+ "loop.chainHeader": "\n--- Chain: {name} ---\n",
68
69
  "loop.exitMarker": "[exit {code} · {duration}]\n",
69
70
  "loop.cwdMissingLog": "[error] working directory does not exist: {cwd}\n",
70
71
  "loop.cwdMissing": "Working directory does not exist: {cwd}",
@@ -78,6 +78,7 @@ export function buildLoopOptions(intervalHuman, input = {}) {
78
78
  verbose: input.verbose ?? false,
79
79
  description,
80
80
  projectId: input.projectId ?? "default",
81
+ offset: input.offset ?? null,
81
82
  },
82
83
  };
83
84
  }
@@ -17,3 +17,25 @@ export function copyToClipboard(text) {
17
17
  }
18
18
  }
19
19
  }
20
+ export function readFromClipboard() {
21
+ const os = platform();
22
+ try {
23
+ if (os === "win32") {
24
+ return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
25
+ }
26
+ else if (os === "darwin") {
27
+ return execFileSync("pbpaste", { encoding: "utf-8" });
28
+ }
29
+ else {
30
+ try {
31
+ return execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" });
32
+ }
33
+ catch {
34
+ return execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" });
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,75 +1,74 @@
1
- {
2
- "name": "loop-task",
3
- "version": "1.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
- "type": "module",
6
- "bin": {
7
- "loop-task": "dist/entry.js"
8
- },
9
- "main": "dist/entry.js",
10
- "files": [
11
- "dist"
12
- ],
13
- "scripts": {
14
- "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')\"",
15
- "prepublishOnly": "npm run build",
16
- "start": "node dist/entry.js",
17
- "dev": "bun src/cli.ts",
18
- "dev:watch": "bun --watch src/cli.ts",
19
- "board": "node dist/entry.js",
20
- "test": "vitest run --exclude '**/background-cli.test.ts'",
21
- "test:all": "vitest run",
22
- "test:watch": "vitest",
23
- "test:coverage": "vitest run --coverage --exclude '**/background-cli.test.ts'",
24
- "lint": "eslint src/ tests/",
25
- "typecheck": "tsc --noEmit",
26
- "release:dry": "npm publish --dry-run",
27
- "release": "npm publish"
28
- },
29
- "keywords": [
30
- "cli",
31
- "loop",
32
- "loop-engineering",
33
- "repeat",
34
- "interval",
35
- "schedule",
36
- "timer",
37
- "cron",
38
- "automation",
39
- "automations",
40
- "devops",
41
- "agent",
42
- "ai-agent",
43
- "coding-agent",
44
- "agent-automation",
45
- "claude-code",
46
- "codex",
47
- "opencode",
48
- "background-tasks",
49
- "scheduler"
50
- ],
51
- "author": "Quique Fdez Guerra",
52
- "license": "MIT",
53
- "engines": {
54
- "node": ">=20.0.0"
55
- },
56
- "dependencies": {
57
- "@opentui/core": "^0.4.1",
58
- "@opentui/react": "^0.4.1",
59
- "commander": "^13.1.0",
60
- "execa": "^9.6.0",
61
- "ms": "^2.1.3",
62
- "react": "^19.2.7"
63
- },
64
- "devDependencies": {
65
- "@types/ms": "^2.1.0",
66
- "@types/node": "^22.15.0",
67
- "@types/react": "^19.2.17",
68
- "@vitest/coverage-v8": "^3.1.0",
69
- "eslint": "^9.25.0",
70
- "tsx": "^4.19.0",
71
- "typescript": "^5.8.0",
72
- "typescript-eslint": "^8.30.0",
73
- "vitest": "^3.1.0"
74
- }
1
+ {
2
+ "name": "loop-task",
3
+ "version": "1.4.7",
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
+ "type": "module",
6
+ "bin": {
7
+ "loop-task": "dist/entry.js"
8
+ },
9
+ "main": "dist/entry.js",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [
14
+ "cli",
15
+ "loop",
16
+ "loop-engineering",
17
+ "repeat",
18
+ "interval",
19
+ "schedule",
20
+ "timer",
21
+ "cron",
22
+ "automation",
23
+ "automations",
24
+ "devops",
25
+ "agent",
26
+ "ai-agent",
27
+ "coding-agent",
28
+ "agent-automation",
29
+ "claude-code",
30
+ "codex",
31
+ "opencode",
32
+ "background-tasks",
33
+ "scheduler"
34
+ ],
35
+ "author": "Quique Fdez Guerra",
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=20.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@opentui/core": "^0.4.1",
42
+ "@opentui/react": "^0.4.1",
43
+ "commander": "^13.1.0",
44
+ "execa": "^9.6.0",
45
+ "ms": "^2.1.3",
46
+ "react": "^19.2.7"
47
+ },
48
+ "devDependencies": {
49
+ "@types/ms": "^2.1.0",
50
+ "@types/node": "^22.15.0",
51
+ "@types/react": "^19.2.17",
52
+ "@vitest/coverage-v8": "^3.1.0",
53
+ "eslint": "^9.25.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.8.0",
56
+ "typescript-eslint": "^8.30.0",
57
+ "vitest": "^3.1.0"
58
+ },
59
+ "scripts": {
60
+ "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')\"",
61
+ "start": "node dist/entry.js",
62
+ "dev": "bun src/cli.ts",
63
+ "dev:watch": "bun --watch src/cli.ts",
64
+ "board": "node dist/entry.js",
65
+ "test": "vitest run --exclude '**/background-cli.test.ts'",
66
+ "test:all": "vitest run",
67
+ "test:watch": "vitest",
68
+ "test:coverage": "vitest run --coverage --exclude '**/background-cli.test.ts'",
69
+ "lint": "eslint src/ tests/",
70
+ "typecheck": "tsc --noEmit",
71
+ "release:dry": "npm publish --dry-run",
72
+ "release": "npm publish"
73
+ }
75
74
  }