loop-task 1.1.0 → 1.3.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 (117) hide show
  1. package/README.md +132 -50
  2. package/dist/board/App.js +202 -0
  3. package/dist/board/board-log.js +18 -0
  4. package/dist/board/components/ActionButtons.js +25 -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 +155 -0
  8. package/dist/board/components/DetailView.js +25 -0
  9. package/dist/board/components/FilterBar.js +15 -0
  10. package/dist/board/components/Footer.js +39 -0
  11. package/dist/board/components/Header.js +16 -0
  12. package/dist/board/components/HelpModal.js +35 -0
  13. package/dist/board/components/Inspector.js +12 -0
  14. package/dist/board/components/LogModal.js +27 -0
  15. package/dist/board/components/Navigator.js +68 -0
  16. package/dist/board/components/RunHistory.js +44 -0
  17. package/dist/board/components/Timeline.js +5 -0
  18. package/dist/board/daemon.js +61 -0
  19. package/dist/board/format.js +95 -0
  20. package/dist/board/hooks/useBoardKeybindings.js +191 -0
  21. package/dist/board/hooks/useBreakpoint.js +6 -0
  22. package/dist/board/hooks/useHoverState.js +11 -0
  23. package/dist/board/hooks/useLogStream.js +32 -0
  24. package/dist/board/hooks/useLoopPolling.js +28 -0
  25. package/dist/board/index.js +32 -0
  26. package/dist/board/state.js +95 -0
  27. package/dist/board/toast.js +65 -0
  28. package/dist/board/types.js +1 -0
  29. package/dist/cli.js +80 -94
  30. package/dist/cli.js.map +1 -1
  31. package/dist/client/commands.d.ts +11 -0
  32. package/dist/client/commands.d.ts.map +1 -0
  33. package/dist/client/commands.js +161 -0
  34. package/dist/client/commands.js.map +1 -0
  35. package/dist/client/ipc.d.ts +5 -0
  36. package/dist/client/ipc.d.ts.map +1 -0
  37. package/dist/client/ipc.js +96 -0
  38. package/dist/client/ipc.js.map +1 -0
  39. package/dist/config/constants.js +14 -0
  40. package/dist/config/paths.js +43 -0
  41. package/dist/core/command-runner.js +80 -0
  42. package/dist/core/foreground-loop.js +78 -0
  43. package/dist/core/log-parser.js +29 -0
  44. package/dist/core/log-rotator.js +25 -0
  45. package/dist/core/loop-controller.js +251 -0
  46. package/dist/daemon/daemon-log.js +11 -0
  47. package/dist/daemon/index.d.ts +2 -0
  48. package/dist/daemon/index.d.ts.map +1 -0
  49. package/dist/daemon/index.js +40 -0
  50. package/dist/daemon/index.js.map +1 -0
  51. package/dist/daemon/manager.d.ts +17 -0
  52. package/dist/daemon/manager.d.ts.map +1 -0
  53. package/dist/daemon/manager.js +181 -0
  54. package/dist/daemon/manager.js.map +1 -0
  55. package/dist/daemon/server.d.ts +15 -0
  56. package/dist/daemon/server.d.ts.map +1 -0
  57. package/dist/daemon/server.js +174 -0
  58. package/dist/daemon/server.js.map +1 -0
  59. package/dist/daemon/spawner.d.ts +3 -0
  60. package/dist/daemon/spawner.d.ts.map +1 -0
  61. package/dist/daemon/spawner.js +82 -0
  62. package/dist/daemon/spawner.js.map +1 -0
  63. package/dist/daemon/state.d.ts +15 -0
  64. package/dist/daemon/state.d.ts.map +1 -0
  65. package/dist/daemon/state.js +120 -0
  66. package/dist/daemon/state.js.map +1 -0
  67. package/dist/duration.js +4 -4
  68. package/dist/entry.js +11 -0
  69. package/dist/esm-loader.js +36 -0
  70. package/dist/i18n/en.json +267 -0
  71. package/dist/i18n/index.js +11 -0
  72. package/dist/ipc/handlers/logs-stream.js +65 -0
  73. package/dist/ipc/send.js +5 -0
  74. package/dist/logger.js +0 -1
  75. package/dist/loop-config.d.ts +13 -0
  76. package/dist/loop-config.d.ts.map +1 -0
  77. package/dist/loop-config.js +74 -0
  78. package/dist/loop-config.js.map +1 -0
  79. package/dist/loop.d.ts +43 -2
  80. package/dist/loop.d.ts.map +1 -1
  81. package/dist/loop.js +247 -2
  82. package/dist/loop.js.map +1 -1
  83. package/dist/open-board.d.ts +2 -0
  84. package/dist/open-board.d.ts.map +1 -0
  85. package/dist/open-board.js +19 -0
  86. package/dist/open-board.js.map +1 -0
  87. package/dist/shared/fs-utils.js +11 -0
  88. package/dist/shared/sleep.js +17 -0
  89. package/dist/shared/tail.js +4 -0
  90. package/dist/tui/app.d.ts +57 -0
  91. package/dist/tui/app.d.ts.map +1 -0
  92. package/dist/tui/app.js +167 -0
  93. package/dist/tui/app.js.map +1 -0
  94. package/dist/tui/create-loop.d.ts +3 -0
  95. package/dist/tui/create-loop.d.ts.map +1 -0
  96. package/dist/tui/create-loop.js +90 -0
  97. package/dist/tui/create-loop.js.map +1 -0
  98. package/dist/tui/dashboard.d.ts +2 -0
  99. package/dist/tui/dashboard.d.ts.map +1 -0
  100. package/dist/tui/dashboard.js +45 -0
  101. package/dist/tui/dashboard.js.map +1 -0
  102. package/dist/tui/events.d.ts +3 -0
  103. package/dist/tui/events.d.ts.map +1 -0
  104. package/dist/tui/events.js +244 -0
  105. package/dist/tui/events.js.map +1 -0
  106. package/dist/tui/popup.d.ts +3 -0
  107. package/dist/tui/popup.d.ts.map +1 -0
  108. package/dist/tui/popup.js +58 -0
  109. package/dist/tui/popup.js.map +1 -0
  110. package/dist/tui/render.d.ts +9 -0
  111. package/dist/tui/render.d.ts.map +1 -0
  112. package/dist/tui/render.js +305 -0
  113. package/dist/tui/render.js.map +1 -0
  114. package/dist/types.d.ts +74 -0
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +0 -1
  117. package/package.json +64 -55
package/README.md CHANGED
@@ -1,87 +1,151 @@
1
+ <div align="center">
2
+
1
3
  # loop-task
2
4
 
3
- A cross-platform CLI that repeatedly executes a shell command at a human-readable interval.
5
+ **Run commands on repeat. Manage them from a terminal board.**
6
+
7
+ `loop-task` is a cross-platform CLI that runs shell commands at human-readable intervals. Create loops in the background, manage them from an interactive TUI board, or run them in the foreground.
8
+
9
+ [![npm version](https://img.shields.io/npm/v/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
10
+ [![npm downloads](https://img.shields.io/npm/dm/loop-task?style=flat-square&color=black)](https://www.npmjs.com/package/loop-task)
11
+ [![license](https://img.shields.io/npm/l/loop-task?style=flat-square&color=black)](./LICENSE)
12
+ [![node](https://img.shields.io/node/v/loop-task?style=flat-square&color=black)](https://nodejs.org)
4
13
 
5
- Inspired by agent loops, but intentionally simple.
14
+ </div>
6
15
 
7
- ## Installation
16
+ ## Quick start
8
17
 
9
18
  ```bash
10
19
  npm install -g loop-task
20
+ loop-task # open the board (requires Bun)
21
+ loop-task start # start the daemon, restore persisted loops
22
+ loop-task new 30m -- npm test # create a background loop
23
+ loop-task run --now 10s -- echo hi # run a loop in the foreground
11
24
  ```
12
25
 
13
- Or use directly with npx:
26
+ Or run it directly:
14
27
 
15
28
  ```bash
16
- npx loop-task 30m npm test
29
+ npx loop-task
30
+ npx loop-task new 30m -- npm test
17
31
  ```
18
32
 
19
- ## Usage
33
+ ## Requirements
20
34
 
21
- ```bash
22
- loop-task [options] <interval> <command>
23
- ```
35
+ - **Node.js >= 20** — required for all commands
36
+ - **Bun >= 1.2** — required for the interactive board only
24
37
 
25
- ### Basic examples
38
+ Install Bun:
26
39
 
27
40
  ```bash
28
- loop-task 30m npm test
29
- loop-task 1h opencode --prompt '/ob-init'
30
- loop-task 1d node sync.js
41
+ npm install -g bun
31
42
  ```
32
43
 
33
- ### With npx
44
+ `start`, `new`, and `run` work with Node alone. The board auto-delegates to Bun when needed.
34
45
 
35
- ```bash
36
- npx loop-task 30m npm test
37
- npx loop-task 1h opencode --prompt '/ob-init'
38
- ```
46
+ ## Commands
39
47
 
40
- ### Options
48
+ | Command | Description |
49
+ | ------- | ----------- |
50
+ | `loop-task` | Open the interactive board (requires Bun) |
51
+ | `loop-task start` | Start the background daemon, restore persisted loops |
52
+ | `loop-task new <interval> -- <command>` | Create a background loop |
53
+ | `loop-task run <interval> -- <command>` | Run a loop in the foreground |
41
54
 
42
- Options must come before the interval:
55
+ ### Options (for `new` and `run`)
43
56
 
44
- | Option | Description |
45
- | ----------------- | -------------------------------- |
46
- | `--now` | Run immediately before waiting |
47
- | `--max-runs <n>` | Stop after N executions |
48
- | `--verbose` | Show execution details |
49
- | `-h, --help` | Display help |
50
- | `-V, --version` | Display version |
57
+ | Option | Description |
58
+ | ------ | ----------- |
59
+ | `--now` | Run immediately before waiting |
60
+ | `--max-runs <n>` | Stop after N executions |
61
+ | `--cwd <dir>` | Working directory for the command |
62
+ | `--verbose` | Show execution details |
63
+ | `-h, --help` | Display help |
64
+ | `-V, --version` | Display version |
51
65
 
52
66
  ## Examples
53
67
 
54
- ### Agent workflows
55
-
56
68
  ```bash
57
- npx loop-task 30m --now -- opencode run "search for missing translation text and translate them, 3 maximum" --model "opencode/big-pickle"
58
- ```
69
+ # Run tests every 30 minutes
70
+ loop-task new 30m -- npm test
59
71
 
60
- ### Run tests every 30 minutes
72
+ # Run immediately, then every hour
73
+ loop-task new --now 1h -- npm test
61
74
 
62
- ```bash
63
- loop-task 30m npm test
75
+ # Run up to 5 times, then stop
76
+ loop-task run --max-runs 5 5m -- npm test
77
+
78
+ # Agent workflow — schedule an AI task every 30 minutes
79
+ loop-task new 30m --now -- opencode run "find and fix lint issues"
80
+
81
+ # Run in a specific directory
82
+ loop-task new 30m --cwd ./packages/api -- npm test
83
+
84
+ # Verbose mode
85
+ loop-task run --verbose 30m -- npm test
64
86
  ```
65
87
 
66
- ### Run immediately, then every hour
88
+ When the command has its own flags, use `--` to stop argument parsing:
67
89
 
68
90
  ```bash
69
- loop-task --now 1h npm test
91
+ loop-task new 30m -- node -e "console.log('hello')"
70
92
  ```
71
93
 
72
- ### Run up to 5 times then stop
94
+ ## The board
73
95
 
74
- ```bash
75
- loop-task --max-runs 5 5m npm test
96
+ The board is the primary way to manage loops. It shows all loops, their status, run history, and logs in a single terminal interface.
97
+
98
+ ### Board controls
99
+
100
+ ```
101
+ ↑/↓, j/k move selection
102
+ Enter edit selected loop
103
+ n create a new loop
104
+ p pause / resume selected loop
105
+ r force run selected loop now
106
+ del delete selected loop
107
+ / search loops
108
+ f cycle status filter
109
+ s cycle sort mode
110
+ ←/→ switch between panels
111
+ h toggle help
112
+ Esc quit
76
113
  ```
77
114
 
78
- ### Verbose mode
115
+ Destructive actions (pause, force run, delete) prompt a confirmation before executing.
116
+
117
+ ### Loop fields
118
+
119
+ When creating or editing a loop:
120
+
121
+ | Field | Description |
122
+ | ----- | ----------- |
123
+ | **Interval** | How often to run (`30s`, `5m`, `1h`, `1d`, `1w`) |
124
+ | **Command** | The full command line |
125
+ | **Description** | Optional label shown in the list; defaults to the command |
126
+ | **Working dir** | Directory the command runs in; defaults to current directory |
127
+ | **Run immediately?** | Run once now, then every interval, or wait the first interval |
128
+ | **Max runs** | Stop after N runs, or leave blank to run forever |
129
+
130
+ ## How it works
79
131
 
80
- ```bash
81
- loop-task --verbose 30m npm test
82
132
  ```
133
+ loop-task (board) ──IPC──► daemon ──► loop 1 (background process)
134
+ ├──► loop 2
135
+ └──► loop 3
136
+ ```
137
+
138
+ - The **daemon** is a background process that manages all loops. It starts automatically when you run `loop-task start` or any command that needs it.
139
+ - The **board** is a terminal UI that connects to the daemon via IPC.
140
+ - Loops **persist to disk** — they survive daemon restarts and system reboots. When the daemon starts, it restores all loops and accounts for elapsed time.
83
141
 
84
- Shows start/end timestamps, exit code, execution duration, and next scheduled run.
142
+ ### Lifecycle
143
+
144
+ 1. `loop-task start` or `loop-task new ...` spawns the daemon if not running
145
+ 2. The daemon creates a loop and persists its state to disk
146
+ 3. `loop-task` opens the board for interactive management
147
+ 4. Closing the board or terminal does **not** stop loops — the daemon keeps running
148
+ 5. After a reboot, `loop-task start` restores all persisted loops with correct timing
85
149
 
86
150
  ## Supported intervals
87
151
 
@@ -95,17 +159,35 @@ Shows start/end timestamps, exit code, execution duration, and next scheduled ru
95
159
 
96
160
  ## Behavior
97
161
 
98
- - **No overlapping**: waits for the command to finish before starting the next interval
99
- - **Resilient**: continues looping even if a command exits with a non-zero code
100
- - **Graceful shutdown**: finishes current execution on Ctrl+C, then exits cleanly
162
+ - **No overlapping** waits for the command to finish before starting the next interval
163
+ - **Resilient** continues looping even if a command exits with a non-zero code
164
+ - **Persistent** loop state is saved after every run; survives restarts
165
+ - **Graceful shutdown** — background loops are daemon-managed; foreground loops finish the current execution on Ctrl+C
101
166
 
102
167
  ## Development
103
168
 
169
+ Requires [Bun](https://bun.sh) >= 1.2 for package management and the board, and [Node.js](https://nodejs.org) >= 20 for the CLI and daemon.
170
+
171
+ ```bash
172
+ bun install
173
+ npm run build
174
+ ```
175
+
176
+ Run locally:
177
+
178
+ ```bash
179
+ bun run dev # board
180
+ node dist/entry.js new --now 30m -- npm test # background loop
181
+ node dist/entry.js run --now --max-runs 1 10s -- echo hello # foreground
182
+ ```
183
+
184
+ Quality gates:
185
+
104
186
  ```bash
105
- pnpm install
106
- pnpm run build
107
- pnpm run test
108
- pnpm run lint
187
+ bun run typecheck
188
+ bun run lint
189
+ bun run test
190
+ npm run build
109
191
  ```
110
192
 
111
193
  ## License
@@ -0,0 +1,202 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useMemo, useRef, useState } from "react";
3
+ import { applyLoopFilters, cycleSortMode, cycleStatusFilter, defaultFilters, } from "./state.js";
4
+ import { ToastStack, useToasts } from "./toast.js";
5
+ import { t } from "../i18n/index.js";
6
+ import { useLoopPolling } from "./hooks/useLoopPolling.js";
7
+ import { useLogStream } from "./hooks/useLogStream.js";
8
+ import { useBoardKeybindings } from "./hooks/useBoardKeybindings.js";
9
+ import { Header } from "./components/Header.js";
10
+ import { FilterBar } from "./components/FilterBar.js";
11
+ import { Navigator } from "./components/Navigator.js";
12
+ import { Inspector } from "./components/Inspector.js";
13
+ import { RunHistory } from "./components/RunHistory.js";
14
+ import { ActionButtons } from "./components/ActionButtons.js";
15
+ import { HelpModal } from "./components/HelpModal.js";
16
+ import { Footer } from "./components/Footer.js";
17
+ import { ConfirmModal } from "./components/ConfirmModal.js";
18
+ import { CreateView, createInitialValues } from "./components/CreateForm.js";
19
+ import { LogModal } from "./components/LogModal.js";
20
+ import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, triggerLoop } from "./daemon.js";
21
+ import { useBreakpoint } from "./hooks/useBreakpoint.js";
22
+ const BOARD_REFRESH_DELAY_MS = 150;
23
+ export function App(props) {
24
+ const { loops, daemonStatus, refresh } = useLoopPolling();
25
+ const [view, setView] = useState("board");
26
+ const [filters, setFilters] = useState(defaultFilters);
27
+ const [sort, setSort] = useState("description");
28
+ const [selectedIndex, setSelectedIndex] = useState(0);
29
+ const [searchActive, setSearchActive] = useState(false);
30
+ const [helpOpen, setHelpOpen] = useState(false);
31
+ const [confirm, setConfirm] = useState(null);
32
+ const [confirmChoice, setConfirmChoice] = useState(0);
33
+ const [editTarget, setEditTarget] = useState(null);
34
+ const [selectedRunIndex, setSelectedRunIndex] = useState(0);
35
+ const [selectedAction, setSelectedAction] = useState(0);
36
+ const [focusedPanel, setFocusedPanel] = useState("loops");
37
+ const [logModalRun, setLogModalRun] = useState(null);
38
+ const [logModalLines, setLogModalLines] = useState([]);
39
+ const [logModalLoading, setLogModalLoading] = useState(false);
40
+ const { toasts, push } = useToasts();
41
+ const breakpoint = useBreakpoint();
42
+ const visible = useMemo(() => applyLoopFilters(loops, filters, sort), [loops, filters, sort]);
43
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, visible.length - 1));
44
+ const selected = visible[clampedIndex] ?? null;
45
+ const selectedId = selected?.id ?? null;
46
+ const prevSelectedId = useRef(null);
47
+ if (selectedId !== prevSelectedId.current) {
48
+ prevSelectedId.current = selectedId;
49
+ setSelectedRunIndex(0);
50
+ }
51
+ const { destroy: destroyLogSocket } = useLogStream(selectedId, view, (error) => push("error", t("board.logStreamError", { message: error.message })));
52
+ function runAction(label, action) {
53
+ return async () => {
54
+ try {
55
+ await action();
56
+ setTimeout(() => {
57
+ void refresh();
58
+ }, BOARD_REFRESH_DELAY_MS);
59
+ push("success", label);
60
+ }
61
+ catch (error) {
62
+ push("error", error instanceof Error ? error.message : String(error));
63
+ }
64
+ };
65
+ }
66
+ function handleOpenRunLog(run) {
67
+ if (!selectedId)
68
+ return;
69
+ setLogModalRun(run);
70
+ setLogModalLoading(true);
71
+ setLogModalLines([]);
72
+ fetchRunLog(selectedId, run.runNumber)
73
+ .then((log) => {
74
+ setLogModalLines(log ? log.split("\n") : []);
75
+ setLogModalLoading(false);
76
+ })
77
+ .catch(() => {
78
+ setLogModalLines([]);
79
+ setLogModalLoading(false);
80
+ });
81
+ }
82
+ function handleAction(action) {
83
+ if (!selected || !selectedId)
84
+ return;
85
+ switch (action) {
86
+ case "pause-resume": {
87
+ const actionLabel = selected.status === "paused" ? t("board.actionResume") : t("board.actionPause");
88
+ const actionVerb = selected.status === "paused" ? t("board.actionResumed") : t("board.actionPaused");
89
+ const actionFn = selected.status === "paused"
90
+ ? () => resumeLoop(selectedId)
91
+ : () => pauseLoop(selectedId);
92
+ setConfirmChoice(0);
93
+ setConfirm({
94
+ message: t("board.confirmPauseResume", { action: actionLabel, id: selectedId }),
95
+ action: runAction(t("board.toastActionId", { verb: actionVerb, id: selectedId }), actionFn),
96
+ });
97
+ break;
98
+ }
99
+ case "run": {
100
+ setConfirmChoice(0);
101
+ setConfirm({
102
+ message: t("board.confirmForceRun", { id: selectedId }),
103
+ action: runAction(t("board.toastTriggered", { id: selectedId }), () => triggerLoop(selectedId)),
104
+ });
105
+ break;
106
+ }
107
+ case "edit": {
108
+ setEditTarget(selected);
109
+ setView("create");
110
+ break;
111
+ }
112
+ case "delete": {
113
+ setConfirmChoice(0);
114
+ setConfirm({
115
+ message: t("board.confirmDelete", { id: selectedId }),
116
+ action: runAction(t("board.toastDeleted", { id: selectedId }), () => deleteLoop(selectedId)),
117
+ });
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ useBoardKeybindings({
123
+ confirm,
124
+ confirmChoice,
125
+ setConfirm,
126
+ setConfirmChoice,
127
+ helpOpen,
128
+ setHelpOpen,
129
+ searchActive,
130
+ setSearchActive,
131
+ view,
132
+ setView,
133
+ setEditTarget,
134
+ selected,
135
+ visibleCount: visible.length,
136
+ setSelectedIndex,
137
+ setFilters,
138
+ setSort,
139
+ onQuit: props.onQuit,
140
+ destroyLogSocket,
141
+ logModalRun,
142
+ setLogModalRun,
143
+ selectedRunIndex,
144
+ setSelectedRunIndex,
145
+ selectedRunCount: selected?.runHistory?.length ?? 0,
146
+ focusedPanel,
147
+ setFocusedPanel,
148
+ selectedAction,
149
+ setSelectedAction,
150
+ onAction: handleAction,
151
+ onOpenRunLog: handleOpenRunLog,
152
+ });
153
+ const counts = {
154
+ total: loops.length,
155
+ running: loops.filter((l) => l.status === "running").length,
156
+ waiting: loops.filter((l) => l.status === "waiting").length,
157
+ paused: loops.filter((l) => l.status === "paused").length,
158
+ };
159
+ const mode = confirm
160
+ ? "confirm"
161
+ : searchActive
162
+ ? "search"
163
+ : helpOpen
164
+ ? "help"
165
+ : view === "create"
166
+ ? "create"
167
+ : "normal";
168
+ return (_jsxs("box", { style: { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#0b0b0b" }, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: focusedPanel, onSearch: (q) => {
169
+ setFilters((prev) => ({ ...prev, query: q }));
170
+ setSelectedIndex(0);
171
+ }, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onNewLoop: () => {
172
+ setEditTarget(null);
173
+ setView("create");
174
+ } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget ? "edit" : "create", editId: editTarget?.id ?? null, initial: createInitialValues(editTarget), onCancel: () => {
175
+ setEditTarget(null);
176
+ setView("board");
177
+ }, onDone: (updated, id) => {
178
+ setEditTarget(null);
179
+ setView("board");
180
+ push("success", updated ? t("board.toastUpdated", { id }) : t("board.toastStarted", { id }));
181
+ setTimeout(() => {
182
+ void refresh();
183
+ }, BOARD_REFRESH_DELAY_MS);
184
+ } })) : (_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", onSelect: (index) => {
185
+ setSelectedIndex(index);
186
+ setFocusedPanel("loops");
187
+ }, onActivate: (index) => {
188
+ setSelectedIndex(index);
189
+ const loop = visible[index];
190
+ if (loop) {
191
+ setEditTarget(loop);
192
+ setView("create");
193
+ }
194
+ } }, `nav-${visible.length}-${visible[0]?.id ?? ""}`), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }, `insp-${selected?.id}-${selected?.status}`), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: focusedPanel === "runs", onSelectRun: (index) => {
195
+ setSelectedRunIndex(index);
196
+ setFocusedPanel("runs");
197
+ }, onOpenRun: handleOpenRunLog }, `rh-${selected?.id}-${selected?.runHistory?.length ?? 0}`), _jsx(ActionButtons, { loop: selected, focused: focusedPanel === "actions", selectedAction: selectedAction, onAction: handleAction }, `ab-${selected?.id}`)] })] })) }, view === "create" ? `${view}:${editTarget?.id ?? "new"}` : view), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => {
198
+ const action = confirm.action;
199
+ setConfirm(null);
200
+ void action();
201
+ }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, {}) : null, logModalRun ? (_jsx(LogModal, { run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
202
+ }
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs";
2
+ import { getBoardLogFile, getDataDir } from "../config/paths.js";
3
+ function formatError(error) {
4
+ if (error instanceof Error) {
5
+ return error.stack ?? `${error.name}: ${error.message}`;
6
+ }
7
+ return typeof error === "string" ? error : JSON.stringify(error);
8
+ }
9
+ export function boardLog(message, error) {
10
+ try {
11
+ fs.mkdirSync(getDataDir(), { recursive: true });
12
+ const suffix = error === undefined ? "" : `\n${formatError(error)}\n`;
13
+ fs.appendFileSync(getBoardLogFile(), `${new Date().toISOString()} ${message}${suffix}`);
14
+ }
15
+ catch {
16
+ // best effort crash logging only
17
+ }
18
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ import { useHoverState } from "../hooks/useHoverState.js";
4
+ import { HOVER_BG } from "../../config/constants.js";
5
+ export function ActionButtons(props) {
6
+ const { loop, focused, selectedAction, onAction } = props;
7
+ if (!loop) {
8
+ 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") }) }));
9
+ }
10
+ const isPaused = loop.status === "paused";
11
+ const pauseResumeLabel = isPaused ? t("board.actionResume") : t("board.actionPause");
12
+ const actions = [
13
+ { key: "pause-resume", label: pauseResumeLabel },
14
+ { key: "run", label: t("board.actionRun") },
15
+ { key: "edit", label: t("board.actionEdit") },
16
+ { key: "delete", label: t("board.actionDelete") },
17
+ ];
18
+ return (_jsx("box", { border: true, borderColor: focused ? "#38bdf8" : undefined, style: { flexDirection: "row", height: 3, flexShrink: 0, paddingLeft: 1, backgroundColor: "#0b0b0b", alignItems: "center" }, children: actions.map((action, i) => (_jsx(ActionButton, { label: action.label, selected: focused && selectedAction === i, onMouseDown: () => onAction(action.key) }, action.key))) }));
19
+ }
20
+ function ActionButton(props) {
21
+ const { isHovered, hoverProps } = useHoverState();
22
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
23
+ const fg = props.selected ? "#ffffff" : isHovered ? "#e5e7eb" : "#9ca3af";
24
+ 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 }) }) }));
25
+ }
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useHoverState } from "../hooks/useHoverState.js";
3
+ const PRIMARY_REST = "#1e3a5f";
4
+ const PRIMARY_HOVER = "#2a4f7a";
5
+ const PRIMARY_FOCUSED = "#1e3a8a";
6
+ const DEFAULT_HOVER = "#334155";
7
+ const DEFAULT_FOCUSED = "#374151";
8
+ export function BoardButton(props) {
9
+ const { label, onMouseDown, focused, variant = "default", marginRight } = props;
10
+ const { isHovered, hoverProps } = useHoverState();
11
+ let bg;
12
+ const fg = "#e5e7eb";
13
+ let border;
14
+ if (variant === "primary") {
15
+ if (focused) {
16
+ bg = PRIMARY_FOCUSED;
17
+ border = "#38bdf8";
18
+ }
19
+ else if (isHovered) {
20
+ bg = PRIMARY_HOVER;
21
+ }
22
+ else {
23
+ bg = PRIMARY_REST;
24
+ }
25
+ }
26
+ else if (variant === "danger") {
27
+ if (focused) {
28
+ bg = "#7f1d1d";
29
+ border = "#f87171";
30
+ }
31
+ else if (isHovered) {
32
+ bg = "#7f1d1d";
33
+ }
34
+ else {
35
+ bg = "#0b0b0b";
36
+ }
37
+ }
38
+ else {
39
+ if (focused) {
40
+ bg = DEFAULT_FOCUSED;
41
+ border = "#38bdf8";
42
+ }
43
+ else if (isHovered) {
44
+ bg = DEFAULT_HOVER;
45
+ }
46
+ else {
47
+ bg = "#0b0b0b";
48
+ }
49
+ }
50
+ return (_jsx("box", { border: true, onMouseDown: onMouseDown, borderColor: border, style: {
51
+ paddingLeft: 2,
52
+ paddingRight: 2,
53
+ marginRight,
54
+ backgroundColor: bg,
55
+ justifyContent: "center",
56
+ alignItems: "center",
57
+ }, ...hoverProps, children: _jsx("text", { fg: fg, children: _jsx("strong", { children: label }) }) }));
58
+ }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { t } from "../../i18n/index.js";
4
+ import { useHoverState } from "../hooks/useHoverState.js";
5
+ import { HOVER_BG } from "../../config/constants.js";
6
+ export function ConfirmModal(props) {
7
+ const { message, choice, onYes, onNo } = props;
8
+ const { width } = useTerminalDimensions();
9
+ return (_jsx("box", { style: {
10
+ position: "absolute",
11
+ top: 0,
12
+ left: 0,
13
+ width: "100%",
14
+ height: "100%",
15
+ justifyContent: "center",
16
+ alignItems: "center",
17
+ zIndex: 100,
18
+ }, children: _jsxs("box", { title: t("board.confirmTitle"), border: true, style: {
19
+ flexDirection: "column",
20
+ padding: 1,
21
+ minWidth: Math.min(44, width - 4),
22
+ backgroundColor: "#111827",
23
+ }, children: [_jsx("text", { children: message }), _jsx("text", { children: " " }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "center" }, children: [_jsx(ConfirmButton, { label: t("board.yes"), onMouseDown: onYes, selected: choice === 1, width: 12, marginRight: 1 }), _jsx(ConfirmButton, { label: t("board.no"), onMouseDown: onNo, selected: choice === 0, width: 12 })] })] }) }));
24
+ }
25
+ function ConfirmButton(props) {
26
+ const { isHovered, hoverProps } = useHoverState();
27
+ const bg = props.selected ? "#1e3a8a" : isHovered ? HOVER_BG : undefined;
28
+ const borderColor = props.selected ? "#38bdf8" : undefined;
29
+ return (_jsx("box", { border: true, onMouseDown: props.onMouseDown, borderColor: borderColor, style: {
30
+ width: props.width,
31
+ backgroundColor: bg,
32
+ justifyContent: "center",
33
+ alignItems: "center",
34
+ marginRight: props.marginRight,
35
+ }, ...hoverProps, children: _jsx("text", { fg: "#e5e7eb", children: _jsx("strong", { children: props.label }) }) }));
36
+ }