loop-task 1.5.5 → 2.0.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 (105) hide show
  1. package/README.md +141 -21
  2. package/dist/board/App.js +1 -1
  3. package/dist/board/components/ActionButtons.js +1 -1
  4. package/dist/board/components/BoardButton.js +1 -1
  5. package/dist/board/components/ConfirmModal.js +1 -1
  6. package/dist/board/components/ContextHelpModal.js +1 -1
  7. package/dist/board/components/CreateForm.js +1 -1
  8. package/dist/board/components/CreateProjectModal.js +1 -1
  9. package/dist/board/components/DeleteProjectConfirm.js +1 -1
  10. package/dist/board/components/DetailView.js +1 -1
  11. package/dist/board/components/EditProjectModal.js +1 -1
  12. package/dist/board/components/FilterBar.js +1 -1
  13. package/dist/board/components/Footer.js +1 -1
  14. package/dist/board/components/Header.js +1 -1
  15. package/dist/board/components/HelpModal.js +1 -1
  16. package/dist/board/components/Inspector.js +1 -1
  17. package/dist/board/components/LogModal.js +1 -1
  18. package/dist/board/components/Navigator.js +1 -1
  19. package/dist/board/components/ProjectsModal.js +1 -1
  20. package/dist/board/components/ProjectsPage.js +1 -1
  21. package/dist/board/components/RunHistory.js +1 -1
  22. package/dist/board/components/SearchBox.js +1 -1
  23. package/dist/board/components/SearchSelect.js +1 -1
  24. package/dist/board/components/TaskBrowser.js +1 -1
  25. package/dist/board/components/TaskFilterBar.js +1 -1
  26. package/dist/board/components/TaskForm.js +1 -1
  27. package/dist/board/components/Timeline.js +1 -1
  28. package/dist/board/focus-context.js +1 -1
  29. package/dist/board/format.js +15 -6
  30. package/dist/board/index.js +1 -1
  31. package/dist/board/toast.js +1 -1
  32. package/dist/cli/import-validator.js +151 -0
  33. package/dist/cli/import-writer.js +77 -0
  34. package/dist/cli.js +111 -17
  35. package/dist/config/constants.js +52 -0
  36. package/dist/daemon/file-watcher.js +171 -0
  37. package/dist/daemon/http-server.js +605 -0
  38. package/dist/daemon/index.js +36 -6
  39. package/dist/daemon/manager.js +94 -13
  40. package/dist/daemon/projects.js +10 -0
  41. package/dist/daemon/server.js +21 -0
  42. package/dist/daemon/task-manager.js +7 -0
  43. package/dist/esm-loader.js +4 -1
  44. package/dist/hooks/useLoopFormValidation.js +140 -0
  45. package/dist/i18n/en.json +237 -17
  46. package/dist/shared/fs-utils.js +34 -1
  47. package/dist/tui/app.js +675 -151
  48. package/dist/tui/commands.js +114 -0
  49. package/dist/tui/components/ActionButtons.js +49 -0
  50. package/dist/tui/components/Button.js +18 -0
  51. package/dist/tui/components/ChainEditor.js +112 -0
  52. package/dist/tui/components/CommandBuilderField.js +143 -0
  53. package/dist/tui/components/CommandEditorModal.js +159 -0
  54. package/dist/tui/components/CommandInput.js +303 -0
  55. package/dist/tui/components/CommandsBrowserModal.js +77 -0
  56. package/dist/tui/components/ConfirmModal.js +9 -0
  57. package/dist/tui/components/ContextHelpModal.js +8 -0
  58. package/dist/tui/components/CreateForm.js +223 -0
  59. package/dist/tui/components/DebugPanel.js +8 -0
  60. package/dist/tui/components/ExportModal.js +37 -0
  61. package/dist/tui/components/FilterBar.js +13 -0
  62. package/dist/tui/components/FocusableButton.js +16 -0
  63. package/dist/tui/components/FocusableInput.js +69 -0
  64. package/dist/tui/components/FocusableList.js +46 -0
  65. package/dist/tui/components/FocusableSearchSelect.js +82 -0
  66. package/dist/tui/components/Footer.js +42 -0
  67. package/dist/tui/components/Header.js +40 -0
  68. package/dist/tui/components/HelpGuideModal.js +21 -0
  69. package/dist/tui/components/HelpModal.js +59 -0
  70. package/dist/tui/components/InlineCommandEditor.js +143 -0
  71. package/dist/tui/components/Inspector.js +31 -0
  72. package/dist/tui/components/LeftPanel.js +33 -0
  73. package/dist/tui/components/LogModal.js +121 -0
  74. package/dist/tui/components/Modal.js +11 -0
  75. package/dist/tui/components/Navigator.js +68 -0
  76. package/dist/tui/components/PatchEditForm.js +78 -0
  77. package/dist/tui/components/ProjectForm.js +64 -0
  78. package/dist/tui/components/ProjectsModal.js +40 -0
  79. package/dist/tui/components/ProjectsPage.js +99 -0
  80. package/dist/tui/components/RightPanel.js +34 -0
  81. package/dist/tui/components/RunHistory.js +108 -0
  82. package/dist/tui/components/SearchSelect.js +67 -0
  83. package/dist/tui/components/SelectModal.js +76 -0
  84. package/dist/tui/components/TabBar.js +20 -0
  85. package/dist/tui/components/TaskBrowser.js +96 -0
  86. package/dist/tui/components/TaskFilterBar.js +9 -0
  87. package/dist/tui/components/TaskForm.js +134 -0
  88. package/dist/tui/components/TaskPickerModal.js +60 -0
  89. package/dist/tui/components/Toast.js +37 -0
  90. package/dist/tui/components/WelcomeScreen.js +70 -0
  91. package/dist/tui/components/WizardForm.js +178 -0
  92. package/dist/tui/daemon.js +142 -0
  93. package/dist/tui/format.js +111 -0
  94. package/dist/tui/hooks/useBreakpoint.js +7 -0
  95. package/dist/tui/hooks/useHoverState.js +11 -0
  96. package/dist/tui/hooks/useLogStream.js +32 -0
  97. package/dist/tui/hooks/useLoopPolling.js +28 -0
  98. package/dist/tui/index.js +24 -0
  99. package/dist/tui/router.js +16 -0
  100. package/dist/tui/state.js +175 -0
  101. package/dist/tui/theme.js +96 -0
  102. package/dist/tui/types.js +1 -0
  103. package/dist/tui/utils/paste.js +10 -0
  104. package/dist/tui/utils/validation.js +56 -0
  105. package/package.json +13 -8
package/dist/tui/app.js CHANGED
@@ -1,167 +1,691 @@
1
- export class BoardApp {
2
- widgets;
3
- loops = [];
4
- selectedId = null;
5
- activeView = "board";
6
- inputMode = "normal";
7
- modalState = "none";
8
- filters = {
9
- status: "all",
10
- intervalBucket: "all",
11
- recentActivity: "all",
12
- query: "",
13
- };
14
- loopSortMode = "status";
15
- liveLogLoopId = null;
16
- daemonStatus = "starting";
17
- loadingState = "starting";
18
- statusMessage = "";
19
- logSocket = null;
20
- constructor(widgets) {
21
- this.widgets = widgets;
22
- }
23
- destroyLogStream() {
24
- if (this.logSocket) {
25
- this.logSocket.destroy();
26
- this.logSocket = null;
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo, useCallback } from "react";
3
+ import { Box, useInput, useApp } from "ink";
4
+ import { useLoopPolling } from "./hooks/useLoopPolling.js";
5
+ import { useLogStream } from "./hooks/useLogStream.js";
6
+ import { useBreakpoint } from "./hooks/useBreakpoint.js";
7
+ import { ToastStack, useToasts } from "./components/Toast.js";
8
+ import { Header } from "./components/Header.js";
9
+ import { LeftPanel } from "./components/LeftPanel.js";
10
+ import { RightPanel } from "./components/RightPanel.js";
11
+ import { CommandInput } from "./components/CommandInput.js";
12
+ import { TaskForm } from "./components/TaskForm.js";
13
+ import { CreateView } from "./components/CreateForm.js";
14
+ import { ProjectFormView } from "./components/ProjectForm.js";
15
+ import { LogModal } from "./components/LogModal.js";
16
+ import { CommandsBrowserModal } from "./components/CommandsBrowserModal.js";
17
+ import { DebugPanel, MAX_ENTRIES } from "./components/DebugPanel.js";
18
+ import { ContextHelpModal } from "./components/ContextHelpModal.js";
19
+ import { ExportModal } from "./components/ExportModal.js";
20
+ import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, triggerLoop, listTasks, deleteTask, listProjects, exportConfig } from "./daemon.js";
21
+ import { applyLoopFilters, applyProjectFilters, cycleSortMode, cycleStatusFilter, cycleProjectSortMode, cycleProjectHasLoopsFilter, cycleProjectIsSystemFilter, defaultFilters, defaultProjectFilters, resolveInputOwner } from "./state.js";
22
+ import { t } from "../i18n/index.js";
23
+ import { copyToClipboard } from "../shared/clipboard.js";
24
+ import { POLL_MS } from "../config/constants.js";
25
+ import { darkTheme as theme } from "./theme.js";
26
+ import { useRouter } from "./router.js";
27
+ import { commandLine } from "./format.js";
28
+ const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);
29
+ const FORM_VIEWS = ["create", "task-create", "task-edit", "project-create", "project-edit"];
30
+ function viewKey(view, editTarget, editTask) {
31
+ if (view === "create")
32
+ return `${view}:${editTarget?.id ?? "new"}`;
33
+ if (view === "task-edit")
34
+ return `${view}:${editTask?.id ?? "new"}`;
35
+ return view;
36
+ }
37
+ function isBoardView(view) {
38
+ return view === "board";
39
+ }
40
+ export function App(props) {
41
+ const { onQuit } = props;
42
+ const { exit } = useApp();
43
+ const { loops, daemonStatus, refresh } = useLoopPolling();
44
+ const { view, push, pop } = useRouter("board");
45
+ // ── Tab and panel state (8.1, 8.2) ──
46
+ const [activeTab, setActiveTab] = useState("loops");
47
+ const [focusedPanel, setFocusedPanel] = useState("left");
48
+ // ── Filter state ──
49
+ const [filters, setFilters] = useState(defaultFilters);
50
+ const [sort, setSort] = useState("description");
51
+ const [selectedIndex, setSelectedIndex] = useState(0);
52
+ const [editTarget, setEditTarget] = useState(null);
53
+ const [cloneMode, setCloneMode] = useState(false);
54
+ const [editTask, setEditTask] = useState(null);
55
+ const [editProject, setEditProject] = useState(null);
56
+ const [pendingTaskSelection, setPendingTaskSelection] = useState(null);
57
+ const [selectedRunIndex, setSelectedRunIndex] = useState(0);
58
+ const [logModalRun, setLogModalRun] = useState(null);
59
+ const [logModalLoopId, setLogModalLoopId] = useState(null);
60
+ const [logModalLines, setLogModalLines] = useState([]);
61
+ const [logModalLoading, setLogModalLoading] = useState(false);
62
+ const [tasks, setTasks] = useState([]);
63
+ const [taskSelectedIndex, setTaskSelectedIndex] = useState(0);
64
+ const [taskQuery, setTaskQuery] = useState("");
65
+ const [projects, setProjects] = useState([]);
66
+ const [currentProjectId, setCurrentProjectId] = useState("all");
67
+ const [projectSelectedIndex, setProjectSelectedIndex] = useState(0);
68
+ const [projectFilters, setProjectFilters] = useState(defaultProjectFilters);
69
+ // ── Overlay state ──
70
+ const [commandsBrowserOpen, setCommandsBrowserOpen] = useState(false);
71
+ const [contextHelpOpen, setContextHelpOpen] = useState(false);
72
+ const [exportModal, setExportModal] = useState(null);
73
+ // ── Confirm state (8.3) ──
74
+ const [confirmState, setConfirmState] = useState(null);
75
+ // ── Search state (command-driven search) ──
76
+ const [searchState, setSearchState] = useState(null);
77
+ const [searchValue, setSearchValue] = useState("");
78
+ const [debugMode, setDebugMode] = useState(false);
79
+ const [debugEntries, setDebugEntries] = useState([]);
80
+ const [chordState, setChordState] = useState(null);
81
+ // ── Command bar input state (for InputOwner resolution) ──
82
+ const [commandBarHasText, setCommandBarHasText] = useState(false);
83
+ const [commandBarDropdownOpen, setCommandBarDropdownOpen] = useState(false);
84
+ const { toasts, push: pushToast } = useToasts();
85
+ const breakpoint = useBreakpoint();
86
+ const visible = useMemo(() => applyLoopFilters(currentProjectId === "all" ? loops : loops.filter((l) => (l.projectId ?? "default") === currentProjectId), filters, sort), [loops, filters, sort, currentProjectId]);
87
+ const clampedIndex = Math.min(selectedIndex, Math.max(0, visible.length - 1));
88
+ const selected = visible[clampedIndex] ?? null;
89
+ const selectedId = selected?.id ?? null;
90
+ useEffect(() => { setSelectedRunIndex(0); }, [selected?.id]);
91
+ useLogStream(selectedId, view, (error) => pushToast("error", error.message));
92
+ const filteredTasks = useMemo(() => {
93
+ if (!taskQuery)
94
+ return tasks;
95
+ const q = taskQuery.toLowerCase();
96
+ return tasks.filter((t) => `${t.id} ${t.name} ${t.command}`.toLowerCase().includes(q));
97
+ }, [tasks, taskQuery]);
98
+ const taskClampedIndex = Math.min(taskSelectedIndex, Math.max(0, filteredTasks.length - 1));
99
+ const selectedTask = filteredTasks[taskClampedIndex] ?? null;
100
+ const filteredProjects = useMemo(() => applyProjectFilters(projects, loops, projectFilters), [projects, loops, projectFilters]);
101
+ const projectClampedIndex = Math.min(projectSelectedIndex, Math.max(0, filteredProjects.length - 1));
102
+ const selectedProjectEntity = filteredProjects[projectClampedIndex] ?? null;
103
+ const projectLoopCount = selectedProjectEntity
104
+ ? loops.filter((l) => (l.projectId ?? "default") === selectedProjectEntity.id).length
105
+ : 0;
106
+ async function refreshTasks() {
107
+ try {
108
+ setTasks(await listTasks());
27
109
  }
28
- this.liveLogLoopId = null;
29
- }
30
- setInputMode(mode) {
31
- this.inputMode = mode;
32
- }
33
- setStatusMessage(message) {
34
- this.statusMessage = message;
35
- }
36
- setModalState(state) {
37
- this.modalState = state;
38
- }
39
- setActiveView(view) {
40
- this.activeView = view;
41
- }
42
- setDaemonStatus(status) {
43
- this.daemonStatus = status;
44
- }
45
- setLoadingState(state) {
46
- this.loadingState = state;
47
- }
48
- cycleSortMode() {
49
- this.loopSortMode =
50
- this.loopSortMode === "status"
51
- ? "recent"
52
- : this.loopSortMode === "recent"
53
- ? "created"
54
- : "status";
55
- }
56
- cycleStatusFilter() {
57
- this.filters.status =
58
- this.filters.status === "all"
59
- ? "running"
60
- : this.filters.status === "running"
61
- ? "sleeping"
62
- : this.filters.status === "sleeping"
63
- ? "paused"
64
- : this.filters.status === "paused"
65
- ? "stopped"
66
- : "all";
67
- }
68
- setFilterStatus(value) {
69
- this.filters.status = value;
110
+ catch { /* ignore */ }
70
111
  }
71
- setFilterIntervalBucket(value) {
72
- this.filters.intervalBucket = value;
112
+ async function refreshProjects() {
113
+ try {
114
+ setProjects(await listProjects());
115
+ }
116
+ catch { /* ignore */ }
73
117
  }
74
- setFilterRecentActivity(value) {
75
- this.filters.recentActivity = value;
118
+ useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
119
+ useEffect(() => {
120
+ const timer = setInterval(() => { void refreshTasks(); void refreshProjects(); }, POLL_MS);
121
+ return () => clearInterval(timer);
122
+ }, []);
123
+ function runAction(label, action) {
124
+ return async () => {
125
+ try {
126
+ await action();
127
+ void refresh();
128
+ pushToast("success", label);
129
+ }
130
+ catch (error) {
131
+ pushToast("error", error instanceof Error ? error.message : String(error));
132
+ }
133
+ };
76
134
  }
77
- setSearchQuery(value) {
78
- this.filters.query = value;
135
+ function handleOpenRunLog(run) {
136
+ if (!selectedId)
137
+ return;
138
+ setLogModalRun(run);
139
+ setLogModalLoopId(selectedId);
140
+ if (run.status === "running") {
141
+ setLogModalLoading(false);
142
+ setLogModalLines([]);
143
+ return;
144
+ }
145
+ setLogModalLoading(true);
146
+ setLogModalLines([]);
147
+ fetchRunLog(selectedId, run.runNumber)
148
+ .then((log) => {
149
+ const lines = log
150
+ ? log.split("\n").map((l) => l.replace(/\r$/, ""))
151
+ : [];
152
+ setLogModalLines(lines);
153
+ setLogModalLoading(false);
154
+ })
155
+ .catch((err) => {
156
+ setLogModalLines([]);
157
+ setLogModalLoading(false);
158
+ pushToast("error", err.message ?? "Failed to load log");
159
+ });
79
160
  }
80
- visibleLoops() {
81
- const query = this.filters.query.trim().toLowerCase();
82
- return [...this.loops]
83
- .filter((loop) => {
84
- if (this.filters.status !== "all" && loop.status !== this.filters.status) {
85
- return false;
86
- }
87
- if (this.filters.intervalBucket !== "all") {
88
- const intervalBucket = loop.interval <= 60_000
89
- ? "short"
90
- : loop.interval <= 3_600_000
91
- ? "medium"
92
- : "long";
93
- if (intervalBucket !== this.filters.intervalBucket) {
94
- return false;
161
+ const handleContextualCopy = useCallback(() => {
162
+ const copyHandlers = {
163
+ loops: () => {
164
+ if (!selected)
165
+ return;
166
+ const text = selected.commandRaw
167
+ ? selected.commandRaw.split("\n").map((l) => l.trim()).filter(Boolean).join(" ")
168
+ : commandLine(selected.command, selected.commandArgs);
169
+ copyToClipboard(text);
170
+ pushToast("info", t("board.toastTextCopied"));
171
+ },
172
+ tasks: () => {
173
+ if (!selectedTask)
174
+ return;
175
+ const text = selectedTask.commandRaw
176
+ ? selectedTask.commandRaw.split("\n").map((l) => l.trim()).filter(Boolean).join(" ")
177
+ : commandLine(selectedTask.command, selectedTask.commandArgs);
178
+ copyToClipboard(text);
179
+ pushToast("info", t("board.toastTextCopied"));
180
+ },
181
+ projects: () => {
182
+ if (!selectedProjectEntity)
183
+ return;
184
+ copyToClipboard(selectedProjectEntity.name);
185
+ pushToast("info", t("board.toastTextCopied"));
186
+ },
187
+ };
188
+ const handler = copyHandlers[activeTab];
189
+ if (handler)
190
+ handler();
191
+ }, [activeTab, selected, selectedTask, selectedProjectEntity, pushToast]);
192
+ // ── Command handler — dictionary dispatch, no switch/case ──
193
+ const commandHandlers = {
194
+ edit: () => {
195
+ if (activeTab === "loops" && selected) {
196
+ setCloneMode(false);
197
+ setEditTarget(selected);
198
+ if (selected.taskId) {
199
+ const task = tasks.find((t) => t.id === selected.taskId);
200
+ setPendingTaskSelection(task ? { id: task.id, name: task.name } : null);
95
201
  }
202
+ else {
203
+ setPendingTaskSelection(null);
204
+ }
205
+ push("create");
206
+ }
207
+ else if (activeTab === "tasks" && selectedTask) {
208
+ setEditTask(selectedTask);
209
+ push("task-edit");
210
+ }
211
+ else if (activeTab === "projects" && selectedProjectEntity && !selectedProjectEntity.isSystem) {
212
+ setEditProject(selectedProjectEntity);
213
+ push("project-edit");
96
214
  }
97
- if (this.filters.recentActivity !== "all") {
98
- const baseline = loop.lastRunAt ?? loop.createdAt;
99
- const activityAgeMs = Date.now() - new Date(baseline).getTime();
100
- const activityBucket = activityAgeMs <= 15 * 60_000 ? "active" : "stale";
101
- if (activityBucket !== this.filters.recentActivity) {
102
- return false;
215
+ },
216
+ clone: () => {
217
+ if (activeTab === "loops" && selected) {
218
+ setCloneMode(true);
219
+ setEditTarget(selected);
220
+ push("create");
221
+ }
222
+ },
223
+ delete: () => {
224
+ if (activeTab === "loops" && selected) {
225
+ setConfirmState({
226
+ prompt: t("confirm.deleteLoop", { name: selected.description || selected.id }),
227
+ onConfirm: () => { void deleteLoop(selected.id).then(() => { void refresh(); }); },
228
+ });
229
+ }
230
+ else if (activeTab === "tasks" && selectedTask) {
231
+ setConfirmState({
232
+ prompt: t("confirm.deleteTask", { id: selectedTask.id }),
233
+ onConfirm: () => { void deleteTask(selectedTask.id).then(() => { void refreshTasks(); }); },
234
+ });
235
+ }
236
+ else if (activeTab === "projects" && selectedProjectEntity) {
237
+ setConfirmState({
238
+ prompt: t("confirm.deleteProject", { name: selectedProjectEntity.name }),
239
+ onConfirm: async () => {
240
+ try {
241
+ const { deleteProject: dp } = await import("./daemon.js");
242
+ await dp(selectedProjectEntity.id);
243
+ pushToast("success", t("project.toastDeleted", { name: selectedProjectEntity.name }));
244
+ setProjectSelectedIndex(0);
245
+ void refreshProjects();
246
+ }
247
+ catch (e) {
248
+ pushToast("error", e.message);
249
+ }
250
+ },
251
+ });
252
+ }
253
+ },
254
+ pause: () => {
255
+ if (activeTab === "loops" && selected) {
256
+ void runAction(t("board.toastPaused", { desc: selected.description }), () => pauseLoop(selected.id))();
257
+ }
258
+ },
259
+ play: () => {
260
+ if (activeTab === "loops" && selected) {
261
+ void runAction(t("board.toastResumed", { desc: selected.description }), () => resumeLoop(selected.id))();
262
+ }
263
+ },
264
+ stop: () => {
265
+ if (activeTab === "loops" && selected) {
266
+ setConfirmState({
267
+ prompt: t("confirm.stopLoop", { name: selected.description || selected.id }),
268
+ onConfirm: () => { void stopLoop(selected.id).then(() => { void refresh(); }); },
269
+ });
270
+ }
271
+ },
272
+ trigger: () => {
273
+ if (activeTab === "loops" && selected) {
274
+ void runAction(t("board.toastTriggered", { desc: selected.description }), () => triggerLoop(selected.id))();
275
+ }
276
+ },
277
+ "new-loop": () => { setEditTarget(null); push("create"); },
278
+ "new-task": () => { setEditTask(null); push("task-create"); },
279
+ "new-project": () => { setEditProject(null); setActiveTab("projects"); push("project-create"); },
280
+ "project-filter-loops": () => { setProjectFilters((prev) => ({ ...prev, hasLoops: cycleProjectHasLoopsFilter(prev.hasLoops) })); },
281
+ "project-filter-type": () => { setProjectFilters((prev) => ({ ...prev, isSystem: cycleProjectIsSystemFilter(prev.isSystem) })); },
282
+ "project-sort": () => { setProjectFilters((prev) => ({ ...prev, sort: cycleProjectSortMode(prev.sort) })); },
283
+ "all-commands": () => { setCommandsBrowserOpen(true); },
284
+ help: () => { setCommandsBrowserOpen(true); },
285
+ search: () => { setSearchValue(""); setSearchState({ active: true }); },
286
+ "filter-status": () => { setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })); },
287
+ sort: () => { setSort((prev) => cycleSortMode(prev)); },
288
+ "filter-project": () => {
289
+ const ids = ["all", ...projects.map((p) => p.id)];
290
+ const idx = ids.indexOf(currentProjectId);
291
+ const nextId = ids[(idx + 1) % ids.length] ?? "all";
292
+ setCurrentProjectId(nextId);
293
+ },
294
+ debug: () => { setDebugMode((prev) => !prev); },
295
+ logs: () => {
296
+ if (activeTab === "loops" && selected) {
297
+ const runs = selected.runHistory;
298
+ if (runs && runs.length > 0) {
299
+ handleOpenRunLog(runs[runs.length - 1]);
103
300
  }
104
301
  }
105
- if (!query) {
106
- return true;
107
- }
108
- const haystack = [
109
- loop.id,
110
- loop.command,
111
- ...loop.commandArgs,
112
- loop.status,
113
- loop.intervalHuman,
114
- ]
115
- .join(" ")
116
- .toLowerCase();
117
- return haystack.includes(query);
118
- })
119
- .sort((left, right) => this.compareLoops(left, right));
120
- }
121
- selectedVisibleLoop() {
122
- const loops = this.visibleLoops();
123
- const index = this.selectedIndex();
124
- if (index < 0 || index >= loops.length) {
125
- return null;
302
+ },
303
+ select: () => {
304
+ if (selectedTask) {
305
+ setPendingTaskSelection({ id: selectedTask.id, name: selectedTask.name });
306
+ pop();
307
+ }
308
+ },
309
+ api: () => {
310
+ const port = process.env.LOOP_CLI_HTTP_PORT ?? "8845";
311
+ const baseUrl = `http://127.0.0.1:${port}`;
312
+ pushToast("info", `API: ${baseUrl} | Swagger: ${baseUrl}/api/docs | OpenAPI: ${baseUrl}/api/openapi.json`);
313
+ },
314
+ status: () => { pushToast("info", `Command "status" coming soon`); },
315
+ export: () => {
316
+ exportConfig()
317
+ .then(({ json, filePath }) => setExportModal({ json, filePath, error: null }))
318
+ .catch((e) => setExportModal({ json: "", filePath: null, error: e instanceof Error ? e.message : String(e) }));
319
+ },
320
+ import: () => { pushToast("info", `Command "import" coming soon`); },
321
+ };
322
+ function handleCommand(value) {
323
+ const handler = commandHandlers[value];
324
+ if (handler) {
325
+ handler();
126
326
  }
127
- return loops[index] ?? null;
128
- }
129
- syncSelection() {
130
- const loops = this.visibleLoops();
131
- if (loops.length === 0) {
132
- this.widgets.loopList.select(0);
133
- this.selectedId = null;
134
- return;
327
+ else {
328
+ pushToast("error", `Unknown command: ${value}`);
135
329
  }
136
- const selectedIndex = this.selectedId
137
- ? loops.findIndex((loop) => loop.id === this.selectedId)
138
- : -1;
139
- this.widgets.loopList.select(selectedIndex >= 0 ? selectedIndex : 0);
140
- this.selectedId = loops[selectedIndex >= 0 ? selectedIndex : 0]?.id ?? null;
141
330
  }
142
- compareLoops(left, right) {
143
- if (this.loopSortMode === "created") {
144
- return right.createdAt.localeCompare(left.createdAt);
145
- }
146
- if (this.loopSortMode === "recent") {
147
- return (right.lastRunAt ?? right.createdAt).localeCompare(left.lastRunAt ?? left.createdAt);
148
- }
149
- const statusOrder = {
150
- running: 0,
151
- sleeping: 1,
152
- paused: 2,
153
- stopped: 3,
331
+ // ── Confirm handlers (8.3) ──
332
+ const handleConfirmYes = useCallback(() => {
333
+ if (confirmState) {
334
+ confirmState.onConfirm();
335
+ setConfirmState(null);
336
+ }
337
+ }, [confirmState]);
338
+ const handleConfirmCancel = useCallback(() => {
339
+ setConfirmState(null);
340
+ }, []);
341
+ const cancelCreate = () => { setEditTarget(null); setCloneMode(false); setPendingTaskSelection(null); pop(); };
342
+ const cancelTask = () => { setEditTask(null); pop(); };
343
+ const cancelProject = () => { setEditProject(null); pop(); };
344
+ const handleChooseTask = (task) => {
345
+ setPendingTaskSelection(task);
346
+ };
347
+ const onCreateDone = (updated, _id, desc) => {
348
+ setEditTarget(null);
349
+ setCloneMode(false);
350
+ setPendingTaskSelection(null);
351
+ pop();
352
+ pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
353
+ void refresh();
354
+ };
355
+ const onTaskDone = (updated, id) => {
356
+ setEditTask(null);
357
+ void refreshTasks();
358
+ pop();
359
+ pushToast("success", updated ? t("board.toastTaskUpdated", { id }) : t("board.toastTaskCreated", { id }));
360
+ };
361
+ const onProjectDone = (updated, name) => {
362
+ setEditProject(null);
363
+ void refreshProjects();
364
+ pop();
365
+ pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
366
+ };
367
+ // ── Resolve query for LeftPanel based on activeTab + search state ──
368
+ const leftPanelQuery = searchState?.active
369
+ ? searchValue
370
+ : activeTab === "tasks" ? taskQuery : filters.query;
371
+ // ── Search handlers ──
372
+ const handleSearchChange = useCallback((value) => {
373
+ setSearchValue(value);
374
+ if (activeTab === "tasks") {
375
+ setTaskQuery(value);
376
+ }
377
+ else {
378
+ setFilters((prev) => ({ ...prev, query: value }));
379
+ }
380
+ }, [activeTab]);
381
+ const handleSearchSubmit = useCallback(() => {
382
+ setSearchState(null);
383
+ }, []);
384
+ const handleSearchCancel = useCallback(() => {
385
+ setSearchValue("");
386
+ if (activeTab === "tasks") {
387
+ setTaskQuery("");
388
+ }
389
+ else {
390
+ setFilters((prev) => ({ ...prev, query: "" }));
391
+ }
392
+ setSearchState(null);
393
+ }, [activeTab]);
394
+ // ── Command context (8.3) ──
395
+ const commandContext = useMemo(() => ({ activeTab, selectedLoop: selected, selectedTask, selectedProject: selectedProjectEntity }), [activeTab, selected, selectedTask]);
396
+ // Contextual action for the focused panel: shared by Ctrl+Enter and, for
397
+ // terminals that collapse Ctrl+Enter to plain Enter, by Enter on an empty
398
+ // command bar. Loops-left/tasks/projects -> edit; loops-right -> latest log.
399
+ const triggerContextualAction = () => {
400
+ if (!isBoardView(view) || logModalRun || commandsBrowserOpen || confirmState || searchState?.active)
401
+ return;
402
+ if (chordState)
403
+ setChordState(null);
404
+ const editSelectedLoop = () => {
405
+ if (!selected)
406
+ return;
407
+ setCloneMode(false);
408
+ setEditTarget(selected);
409
+ const task = selected.taskId ? tasks.find((t) => t.id === selected.taskId) : null;
410
+ setPendingTaskSelection(task ? { id: task.id, name: task.name } : null);
411
+ push("create");
154
412
  };
155
- const byStatus = statusOrder[left.status] - statusOrder[right.status];
156
- if (byStatus !== 0) {
157
- return byStatus;
413
+ const handlers = {
414
+ "tasks:": () => { if (selectedTask)
415
+ handleCommand("edit"); },
416
+ "projects:": () => { if (selectedProjectEntity)
417
+ handleCommand("edit"); },
418
+ "loops:right": () => {
419
+ if (!selected)
420
+ return;
421
+ const runs = selected.runHistory;
422
+ if (runs && runs.length > 0)
423
+ handleOpenRunLog(runs[runs.length - 1]);
424
+ else
425
+ editSelectedLoop();
426
+ },
427
+ "loops:left": editSelectedLoop,
428
+ };
429
+ const handlerKey = activeTab !== "loops" ? `${activeTab}:` : `loops:${focusedPanel}`;
430
+ handlers[handlerKey]?.();
431
+ };
432
+ // ── Pop topmost overlay layer (Escape handler helper) ──
433
+ const popLayer = () => {
434
+ if (confirmState) {
435
+ setConfirmState(null);
436
+ return true;
158
437
  }
159
- const leftNext = left.nextRunAt ?? left.lastRunAt ?? left.createdAt;
160
- const rightNext = right.nextRunAt ?? right.lastRunAt ?? right.createdAt;
161
- return rightNext.localeCompare(leftNext);
162
- }
163
- selectedIndex() {
164
- return this.widgets.loopList.selected;
438
+ if (searchState?.active) {
439
+ setSearchState(null);
440
+ setSearchValue("");
441
+ return true;
442
+ }
443
+ if (logModalRun) {
444
+ setLogModalRun(null);
445
+ setLogModalLoopId(null);
446
+ return true;
447
+ }
448
+ if (commandsBrowserOpen) {
449
+ setCommandsBrowserOpen(false);
450
+ return true;
451
+ }
452
+ if (exportModal) {
453
+ setExportModal(null);
454
+ return true;
455
+ }
456
+ if (contextHelpOpen) {
457
+ setContextHelpOpen(false);
458
+ return true;
459
+ }
460
+ if (view !== "board") {
461
+ pop();
462
+ return true;
463
+ }
464
+ // Bare board: open quit confirm
465
+ setConfirmState({
466
+ prompt: t("confirm.quit"),
467
+ onConfirm: () => { onQuit(); exit(); },
468
+ });
469
+ return true;
470
+ };
471
+ // ── Global useInput (8.2) ──
472
+ useInput((input, key) => {
473
+ // Ctrl+C always quits if no modal open
474
+ if (key.ctrl && input === "c") {
475
+ if (!logModalRun && !confirmState && !commandsBrowserOpen && !exportModal) {
476
+ setConfirmState({
477
+ prompt: t("confirm.quit"),
478
+ onConfirm: () => { onQuit(); exit(); },
479
+ });
480
+ return;
481
+ }
482
+ }
483
+ // Debug: capture key info if debug mode is on
484
+ if (debugMode) {
485
+ const entry = {
486
+ id: Date.now(),
487
+ input,
488
+ len: input.length,
489
+ ctrl: key.ctrl,
490
+ return: key.return,
491
+ shift: key.shift,
492
+ meta: key.meta,
493
+ tab: key.tab,
494
+ upArrow: key.upArrow,
495
+ downArrow: key.downArrow,
496
+ leftArrow: key.leftArrow,
497
+ rightArrow: key.rightArrow,
498
+ escape: key.escape,
499
+ codes: Array.from(input).map((c) => c.charCodeAt(0)).join(","),
500
+ };
501
+ setDebugEntries((prev) => [entry, ...prev].slice(0, MAX_ENTRIES));
502
+ }
503
+ // Ctrl+Enter: open log (right panel) or edit (left panel)
504
+ // Detection across terminals:
505
+ // - proper terminals: key.ctrl && key.return
506
+ // - Linux: input === "\x0e"
507
+ // - VS Code: a multi-char sequence containing \r (13) or \n (10),
508
+ // e.g. "\\\r" (codes 92,13) - all key flags zero. Plain Enter is "\r" (len 1).
509
+ const isMultiCharEnter = input.length > 1 && (input.includes("\r") || input.includes("\n"));
510
+ const isCtrlEnter = (key.ctrl && key.return) || input === "\x0e" || isMultiCharEnter;
511
+ if (isCtrlEnter && isBoardView(view) && !logModalRun && !commandsBrowserOpen && !confirmState && !searchState?.active) {
512
+ triggerContextualAction();
513
+ return;
514
+ }
515
+ // Ctrl shortcuts: dispatch to command handler (bottom input ignores all ctrl)
516
+ const canShortcut = key.ctrl && isBoardView(view) && !logModalRun && !commandsBrowserOpen && !confirmState && !searchState?.active && !exportModal;
517
+ if (canShortcut) {
518
+ const globalShortcuts = {
519
+ p: () => setCommandsBrowserOpen(true),
520
+ b: () => handleCommand("debug"),
521
+ g: () => handleCommand("api"),
522
+ x: () => handleCommand("export"),
523
+ i: () => handleCommand("import"),
524
+ y: () => handleCommand("status"),
525
+ };
526
+ const filterShortcuts = {
527
+ s: () => handleCommand("search"),
528
+ t: () => handleCommand("filter-status"),
529
+ o: () => handleCommand("sort"),
530
+ p: () => handleCommand("filter-project"),
531
+ };
532
+ const actionShortcuts = {
533
+ n: () => handleCommand(activeTab === "loops" ? "new-loop" : activeTab === "tasks" ? "new-task" : "new-project"),
534
+ e: () => handleCommand("edit"),
535
+ d: () => handleCommand("delete"),
536
+ ...(activeTab === "loops" ? {
537
+ p: () => handleCommand("pause"),
538
+ r: () => handleCommand("play"),
539
+ s: () => handleCommand("stop"),
540
+ t: () => handleCommand("trigger"),
541
+ c: () => handleCommand("clone"),
542
+ o: () => handleCommand("logs"),
543
+ } : {}),
544
+ };
545
+ if (chordState === "ctrl+f" && filterShortcuts[input]) {
546
+ filterShortcuts[input]();
547
+ setChordState(null);
548
+ return;
549
+ }
550
+ if (chordState === "ctrl+a" && actionShortcuts[input]) {
551
+ actionShortcuts[input]();
552
+ setChordState(null);
553
+ return;
554
+ }
555
+ if (chordState) {
556
+ setChordState(null);
557
+ }
558
+ if (input === "f") {
559
+ setChordState("ctrl+f");
560
+ return;
561
+ }
562
+ if (input === "a") {
563
+ setChordState("ctrl+a");
564
+ return;
565
+ }
566
+ const globalHandler = globalShortcuts[input];
567
+ if (globalHandler) {
568
+ globalHandler();
569
+ return;
570
+ }
571
+ }
572
+ // Confirm mode is handled by CommandInput component, not here (non-Escape only)
573
+ if (confirmState && !key.escape)
574
+ return;
575
+ // Search mode is handled by CommandInput component, not here (non-Escape only)
576
+ if (searchState?.active && !key.escape)
577
+ return;
578
+ // ── Escape: pop the topmost overlay layer (2.2) ──
579
+ if (key.escape) {
580
+ popLayer();
581
+ return;
582
+ }
583
+ if (logModalRun) {
584
+ if (input === "q") {
585
+ setLogModalRun(null);
586
+ setLogModalLoopId(null);
587
+ }
588
+ return;
589
+ }
590
+ if (commandsBrowserOpen) {
591
+ return;
592
+ }
593
+ if (contextHelpOpen) {
594
+ // Escape handled by popLayer above; dismiss on any other key
595
+ setContextHelpOpen(false);
596
+ return;
597
+ }
598
+ // Any other modal open: block all input from reaching panels
599
+ if (logModalRun)
600
+ return;
601
+ // Full-screen form views: escape handled by popLayer above
602
+ if (FORM_VIEWS.includes(view))
603
+ return;
604
+ // Board view: Tab cycles panels (8.2), 1/2/3 switch tabs (8.1)
605
+ if (isBoardView(view)) {
606
+ // Ctrl+Arrow: cycle tabs in a loop (loops -> tasks -> projects -> loops)
607
+ if (key.ctrl && key.rightArrow) {
608
+ setActiveTab((prev) => prev === "loops" ? "tasks" : prev === "tasks" ? "projects" : "loops");
609
+ return;
610
+ }
611
+ if (key.ctrl && key.leftArrow) {
612
+ setActiveTab((prev) => prev === "loops" ? "projects" : prev === "tasks" ? "loops" : "tasks");
613
+ return;
614
+ }
615
+ if (key.tab) {
616
+ if (key.shift) {
617
+ setFocusedPanel((prev) => prev === "left" ? "right" : "left");
618
+ }
619
+ else {
620
+ setFocusedPanel((prev) => prev === "left" ? "right" : "left");
621
+ }
622
+ return;
623
+ }
624
+ if (input === "1") {
625
+ setActiveTab("loops");
626
+ return;
627
+ }
628
+ if (input === "2") {
629
+ setActiveTab("tasks");
630
+ return;
631
+ }
632
+ if (input === "3") {
633
+ setActiveTab("projects");
634
+ return;
635
+ }
636
+ }
637
+ });
638
+ // Modals disable panel input behind them; CommandInput stays active for confirm/search
639
+ const anyModalOpen = !!(logModalRun || commandsBrowserOpen || exportModal);
640
+ const commandInputDisabled = anyModalOpen;
641
+ // ── Resolved input owner (1.2) ──
642
+ const inputOwner = useMemo(() => resolveInputOwner({
643
+ modalOpen: !!(logModalRun || commandsBrowserOpen || exportModal || contextHelpOpen || confirmState || searchState?.active),
644
+ commandBarHasText,
645
+ commandBarDropdownOpen,
646
+ }), [logModalRun, commandsBrowserOpen, exportModal, contextHelpOpen, confirmState, searchState?.active, commandBarHasText, commandBarDropdownOpen]);
647
+ const counts = {
648
+ total: loops.length,
649
+ running: loops.filter((l) => l.status === "running").length,
650
+ waiting: loops.filter((l) => l.status === "waiting").length,
651
+ paused: loops.filter((l) => l.status === "paused").length,
652
+ idle: loops.filter((l) => l.status === "idle").length,
653
+ };
654
+ const tabCounts = { loops: loops.length, tasks: tasks.length, projects: projects.length };
655
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, activeTab: activeTab, onTabChange: setActiveTab, tabCounts: tabCounts }), _jsx(Box, { flexGrow: 1, 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, tasks: tasks, 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 === "project-create" || view === "project-edit" ? (_jsx(ProjectFormView, { mode: view === "project-edit" ? "edit" : "create", editProject: view === "project-edit" ? editProject : null, onCancel: cancelProject, onDone: onProjectDone })) : (
656
+ // Board view: left panel + right panel (+ optional debug panel)
657
+ _jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, query: leftPanelQuery, loops: visible, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); }, tasks: filteredTasks, taskSelectedIndex: taskClampedIndex, onTaskSelect: (index) => setTaskSelectedIndex(index), onTaskActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); }, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setActiveTab("projects"), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), projectFilters: projectFilters, projectSelectedIndex: projectClampedIndex, onProjectSelect: (index) => setProjectSelectedIndex(index), onProjectActivate: (index) => { setProjectSelectedIndex(index); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: activeTab, loop: selected, selectedRunIndex: selectedRunIndex, onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog, selectedTask: selectedTask, allTasks: tasks, selectedProject: selectedProjectEntity, projectLoopCount: projectLoopCount, onProjectEdit: () => { if (selectedProjectEntity && !selectedProjectEntity.isSystem) {
658
+ commandHandlers["edit"]?.();
659
+ } }, onProjectDelete: () => { if (selectedProjectEntity && !selectedProjectEntity.isSystem) {
660
+ commandHandlers["delete"]?.();
661
+ } } }), debugMode ? _jsx(DebugPanel, { entries: debugEntries }) : null] })) }, viewKey(view, editTarget, editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: confirmState, searchState: searchState, searchValue: searchValue, onSearchChange: handleSearchChange, onSearchSubmit: handleSearchSubmit, onSearchCancel: handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => {
662
+ setCommandBarHasText(hasText);
663
+ setCommandBarDropdownOpen(dropdownOpen);
664
+ } })) : null, commandsBrowserOpen ? (_jsx(CommandsBrowserModal, { context: commandContext, onClose: () => setCommandsBrowserOpen(false), onExecute: (value) => { setCommandsBrowserOpen(false); handleCommand(value); } })) : null, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : null, exportModal ? (_jsx(ExportModal, { json: exportModal.json, filePath: exportModal.filePath, error: exportModal.error, onClose: () => setExportModal(null), onCopy: () => pushToast("success", t("board.toastCopied")) })) : null, logModalRun ? (_jsx(LogModal, { loopId: logModalLoopId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => { setLogModalRun(null); setLogModalLoopId(null); }, onCopy: () => pushToast("success", t("board.toastCopied")) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
665
+ }
666
+ function createInitialValues(editTarget, currentProjectId) {
667
+ if (!editTarget) {
668
+ return {
669
+ interval: "30m",
670
+ taskMode: "inline",
671
+ command: "",
672
+ cwd: process.cwd(),
673
+ taskId: "",
674
+ description: "",
675
+ runNow: "y",
676
+ maxRuns: "",
677
+ project: currentProjectId,
678
+ };
165
679
  }
680
+ return {
681
+ interval: editTarget.intervalHuman ?? "30m",
682
+ taskMode: editTarget.taskId ? "existing" : "inline",
683
+ command: editTarget.commandRaw ?? [editTarget.command, ...editTarget.commandArgs].join(" "),
684
+ cwd: editTarget.cwd ?? "",
685
+ taskId: editTarget.taskId ?? "",
686
+ description: editTarget.description,
687
+ runNow: "y",
688
+ maxRuns: editTarget.maxRuns?.toString() ?? "",
689
+ project: editTarget.projectId ?? "default",
690
+ };
166
691
  }
167
- //# sourceMappingURL=app.js.map