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.
- package/README.md +132 -50
- package/dist/board/App.js +202 -0
- package/dist/board/board-log.js +18 -0
- package/dist/board/components/ActionButtons.js +25 -0
- package/dist/board/components/BoardButton.js +58 -0
- package/dist/board/components/ConfirmModal.js +36 -0
- package/dist/board/components/CreateForm.js +155 -0
- package/dist/board/components/DetailView.js +25 -0
- package/dist/board/components/FilterBar.js +15 -0
- package/dist/board/components/Footer.js +39 -0
- package/dist/board/components/Header.js +16 -0
- package/dist/board/components/HelpModal.js +35 -0
- package/dist/board/components/Inspector.js +12 -0
- package/dist/board/components/LogModal.js +27 -0
- package/dist/board/components/Navigator.js +68 -0
- package/dist/board/components/RunHistory.js +44 -0
- package/dist/board/components/Timeline.js +5 -0
- package/dist/board/daemon.js +61 -0
- package/dist/board/format.js +95 -0
- package/dist/board/hooks/useBoardKeybindings.js +191 -0
- package/dist/board/hooks/useBreakpoint.js +6 -0
- package/dist/board/hooks/useHoverState.js +11 -0
- package/dist/board/hooks/useLogStream.js +32 -0
- package/dist/board/hooks/useLoopPolling.js +28 -0
- package/dist/board/index.js +32 -0
- package/dist/board/state.js +95 -0
- package/dist/board/toast.js +65 -0
- package/dist/board/types.js +1 -0
- package/dist/cli.js +80 -94
- package/dist/cli.js.map +1 -1
- package/dist/client/commands.d.ts +11 -0
- package/dist/client/commands.d.ts.map +1 -0
- package/dist/client/commands.js +161 -0
- package/dist/client/commands.js.map +1 -0
- package/dist/client/ipc.d.ts +5 -0
- package/dist/client/ipc.d.ts.map +1 -0
- package/dist/client/ipc.js +96 -0
- package/dist/client/ipc.js.map +1 -0
- package/dist/config/constants.js +14 -0
- package/dist/config/paths.js +43 -0
- package/dist/core/command-runner.js +80 -0
- package/dist/core/foreground-loop.js +78 -0
- package/dist/core/log-parser.js +29 -0
- package/dist/core/log-rotator.js +25 -0
- package/dist/core/loop-controller.js +251 -0
- package/dist/daemon/daemon-log.js +11 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +40 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/manager.d.ts +17 -0
- package/dist/daemon/manager.d.ts.map +1 -0
- package/dist/daemon/manager.js +181 -0
- package/dist/daemon/manager.js.map +1 -0
- package/dist/daemon/server.d.ts +15 -0
- package/dist/daemon/server.d.ts.map +1 -0
- package/dist/daemon/server.js +174 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/spawner.d.ts +3 -0
- package/dist/daemon/spawner.d.ts.map +1 -0
- package/dist/daemon/spawner.js +82 -0
- package/dist/daemon/spawner.js.map +1 -0
- package/dist/daemon/state.d.ts +15 -0
- package/dist/daemon/state.d.ts.map +1 -0
- package/dist/daemon/state.js +120 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/duration.js +4 -4
- package/dist/entry.js +11 -0
- package/dist/esm-loader.js +36 -0
- package/dist/i18n/en.json +267 -0
- package/dist/i18n/index.js +11 -0
- package/dist/ipc/handlers/logs-stream.js +65 -0
- package/dist/ipc/send.js +5 -0
- package/dist/logger.js +0 -1
- package/dist/loop-config.d.ts +13 -0
- package/dist/loop-config.d.ts.map +1 -0
- package/dist/loop-config.js +74 -0
- package/dist/loop-config.js.map +1 -0
- package/dist/loop.d.ts +43 -2
- package/dist/loop.d.ts.map +1 -1
- package/dist/loop.js +247 -2
- package/dist/loop.js.map +1 -1
- package/dist/open-board.d.ts +2 -0
- package/dist/open-board.d.ts.map +1 -0
- package/dist/open-board.js +19 -0
- package/dist/open-board.js.map +1 -0
- package/dist/shared/fs-utils.js +11 -0
- package/dist/shared/sleep.js +17 -0
- package/dist/shared/tail.js +4 -0
- package/dist/tui/app.d.ts +57 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +167 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/create-loop.d.ts +3 -0
- package/dist/tui/create-loop.d.ts.map +1 -0
- package/dist/tui/create-loop.js +90 -0
- package/dist/tui/create-loop.js.map +1 -0
- package/dist/tui/dashboard.d.ts +2 -0
- package/dist/tui/dashboard.d.ts.map +1 -0
- package/dist/tui/dashboard.js +45 -0
- package/dist/tui/dashboard.js.map +1 -0
- package/dist/tui/events.d.ts +3 -0
- package/dist/tui/events.d.ts.map +1 -0
- package/dist/tui/events.js +244 -0
- package/dist/tui/events.js.map +1 -0
- package/dist/tui/popup.d.ts +3 -0
- package/dist/tui/popup.d.ts.map +1 -0
- package/dist/tui/popup.js +58 -0
- package/dist/tui/popup.js.map +1 -0
- package/dist/tui/render.d.ts +9 -0
- package/dist/tui/render.d.ts.map +1 -0
- package/dist/tui/render.js +305 -0
- package/dist/tui/render.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -1
- 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
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/loop-task)
|
|
10
|
+
[](https://www.npmjs.com/package/loop-task)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
[](https://nodejs.org)
|
|
4
13
|
|
|
5
|
-
|
|
14
|
+
</div>
|
|
6
15
|
|
|
7
|
-
##
|
|
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
|
|
26
|
+
Or run it directly:
|
|
14
27
|
|
|
15
28
|
```bash
|
|
16
|
-
npx loop-task
|
|
29
|
+
npx loop-task
|
|
30
|
+
npx loop-task new 30m -- npm test
|
|
17
31
|
```
|
|
18
32
|
|
|
19
|
-
##
|
|
33
|
+
## Requirements
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```
|
|
35
|
+
- **Node.js >= 20** — required for all commands
|
|
36
|
+
- **Bun >= 1.2** — required for the interactive board only
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
Install Bun:
|
|
26
39
|
|
|
27
40
|
```bash
|
|
28
|
-
|
|
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
|
-
|
|
44
|
+
`start`, `new`, and `run` work with Node alone. The board auto-delegates to Bun when needed.
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
npx loop-task 30m npm test
|
|
37
|
-
npx loop-task 1h opencode --prompt '/ob-init'
|
|
38
|
-
```
|
|
46
|
+
## Commands
|
|
39
47
|
|
|
40
|
-
|
|
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
|
|
55
|
+
### Options (for `new` and `run`)
|
|
43
56
|
|
|
44
|
-
| Option
|
|
45
|
-
|
|
|
46
|
-
| `--now`
|
|
47
|
-
| `--max-runs <n>`
|
|
48
|
-
| `--
|
|
49
|
-
|
|
|
50
|
-
| `-
|
|
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
|
-
|
|
58
|
-
|
|
69
|
+
# Run tests every 30 minutes
|
|
70
|
+
loop-task new 30m -- npm test
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
# Run immediately, then every hour
|
|
73
|
+
loop-task new --now 1h -- npm test
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
loop-task
|
|
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
|
-
|
|
88
|
+
When the command has its own flags, use `--` to stop argument parsing:
|
|
67
89
|
|
|
68
90
|
```bash
|
|
69
|
-
loop-task --
|
|
91
|
+
loop-task new 30m -- node -e "console.log('hello')"
|
|
70
92
|
```
|
|
71
93
|
|
|
72
|
-
|
|
94
|
+
## The board
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
- **Resilient
|
|
100
|
-
- **
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|