loop-task 2.1.11 → 2.1.13

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 (68) hide show
  1. package/dist/app/App.js +3 -1
  2. package/dist/cli.js +88 -2
  3. package/dist/client/commands.js +8 -1
  4. package/dist/client/project-commands.js +7 -2
  5. package/dist/core/context/template.js +1 -1
  6. package/dist/core/context/validate-context.js +19 -0
  7. package/dist/core/loop/chain-executor.js +50 -17
  8. package/dist/core/loop/loop-controller.js +18 -0
  9. package/dist/core/loop/loop-runner.js +8 -0
  10. package/dist/core/loop/run-executor.js +1 -1
  11. package/dist/core/scheduling/index.js +4 -0
  12. package/dist/daemon/http/openapi.js +31 -4
  13. package/dist/daemon/http/route-loops.js +106 -0
  14. package/dist/daemon/http/route-misc.js +16 -0
  15. package/dist/daemon/http/route-projects.js +2 -2
  16. package/dist/daemon/http/route-tasks.js +75 -0
  17. package/dist/daemon/http/routes.js +3 -2
  18. package/dist/daemon/http/server.js +20 -2
  19. package/dist/daemon/index.js +48 -7
  20. package/dist/daemon/managers/loop-entry.js +2 -0
  21. package/dist/daemon/managers/loop-manager.js +4 -4
  22. package/dist/daemon/managers/loop-options.js +1 -0
  23. package/dist/daemon/managers/loop-serialization.js +1 -0
  24. package/dist/daemon/managers/project-manager.js +16 -2
  25. package/dist/daemon/mcp/index.js +2 -0
  26. package/dist/daemon/mcp/openapi-sync.js +50 -0
  27. package/dist/daemon/mcp/server.js +162 -0
  28. package/dist/daemon/mcp/tools.js +368 -0
  29. package/dist/daemon/server/handlers/index.js +8 -1
  30. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  31. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  32. package/dist/daemon/server/index.js +3 -1
  33. package/dist/daemon/settings-manager.js +46 -0
  34. package/dist/duration.js +5 -0
  35. package/dist/entities/loops/filters.js +2 -0
  36. package/dist/entities/tasks/filters.js +1 -1
  37. package/dist/features/commands/commands.js +4 -1
  38. package/dist/features/commands/useCommandHandlers.js +32 -0
  39. package/dist/features/commands/useGlobalShortcuts.js +0 -1
  40. package/dist/features/forms/FormRouter.js +2 -0
  41. package/dist/features/overlays/ExportModal.js +1 -1
  42. package/dist/loop-config.js +8 -4
  43. package/dist/shared/clipboard.js +2 -2
  44. package/dist/shared/config/paths.js +3 -0
  45. package/dist/shared/container/index.js +2 -0
  46. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  47. package/dist/shared/hooks/useUndoRedo.js +1 -1
  48. package/dist/shared/i18n/en.json +31 -4
  49. package/dist/shared/services/project-service.js +4 -4
  50. package/dist/shared/services/settings-service.js +49 -0
  51. package/dist/shared/services/types.js +1 -0
  52. package/dist/shared/ui/FocusableInput.js +1 -1
  53. package/dist/shared/ui/SelectModal.js +1 -1
  54. package/dist/shared/ui/format.js +2 -0
  55. package/dist/shared/utils/syntax.js +3 -3
  56. package/dist/widgets/header/Header.js +15 -2
  57. package/dist/widgets/left-panel/Navigator.js +3 -2
  58. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  59. package/dist/widgets/log-modal/LogModal.js +1 -1
  60. package/dist/widgets/loop-form/CreateForm.js +9 -2
  61. package/dist/widgets/loop-form/WizardForm.js +12 -2
  62. package/dist/widgets/loop-form/useCreateSteps.js +30 -2
  63. package/dist/widgets/loop-form/useHandleComplete.js +23 -3
  64. package/dist/widgets/project-form/ProjectForm.js +11 -2
  65. package/dist/widgets/right-panel/Inspector.js +1 -1
  66. package/dist/widgets/right-panel/RightPanel.js +1 -1
  67. package/dist/widgets/task-form/TaskForm.js +69 -4
  68. package/package.json +4 -2
package/dist/duration.js CHANGED
@@ -5,6 +5,9 @@ export function parseDuration(input) {
5
5
  if (!trimmed) {
6
6
  throw new Error(t("errors.durationEmpty"));
7
7
  }
8
+ if (trimmed === "manual" || trimmed === "0") {
9
+ return 0;
10
+ }
8
11
  const result = ms(trimmed);
9
12
  if (typeof result !== "number" || isNaN(result)) {
10
13
  throw new Error(t("errors.durationInvalid", { input }));
@@ -15,5 +18,7 @@ export function parseDuration(input) {
15
18
  return result;
16
19
  }
17
20
  export function formatDuration(value) {
21
+ if (value === 0)
22
+ return t("format.durationManual");
18
23
  return ms(value, { long: true });
19
24
  }
@@ -12,6 +12,8 @@ const statusOrder = {
12
12
  stopped: 4,
13
13
  };
14
14
  function intervalBucketOf(interval) {
15
+ if (interval === 0)
16
+ return "manual";
15
17
  if (interval <= 60_000)
16
18
  return "short";
17
19
  if (interval <= 3_600_000)
@@ -23,7 +23,7 @@ function compare(left, right, sort) {
23
23
  return byCmd;
24
24
  return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
25
25
  }
26
- // "created"newest first
26
+ // "created" newest first
27
27
  return right.createdAt.localeCompare(left.createdAt);
28
28
  }
29
29
  export function applyTaskFilters(tasks, filters) {
@@ -4,7 +4,10 @@ function globalCommands() {
4
4
  return [
5
5
  { label: t('cmd.help'), value: 'help', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+p' },
6
6
  { label: t('cmd.debug'), value: 'debug', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+b' },
7
- { label: t('cmd.api'), value: 'api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+g' },
7
+ { label: t('cmd.api'), value: 'api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
8
+ { label: t('cmd.toggleApi'), value: 'toggle-api', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
9
+ { label: t('cmd.mcp'), value: 'mcp', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
10
+ { label: t('cmd.toggleMcp'), value: 'toggle-mcp', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL },
8
11
  { label: t('cmd.export'), value: 'export', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+x' },
9
12
  { label: t('cmd.import'), value: 'import', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+i' },
10
13
  { label: t('cmd.status'), value: 'status', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+y' },
@@ -2,6 +2,8 @@ import { t } from "../../shared/i18n/index.js";
2
2
  import { cycleSortMode, cycleStatusFilter } from "../../entities/loops/filters.js";
3
3
  import { cycleProjectSortMode, cycleProjectHasLoopsFilter, cycleProjectIsSystemFilter } from "../../entities/projects/filters.js";
4
4
  import { groupRunsByCycle } from "../../widgets/right-panel/RunHistory.js";
5
+ import { container } from "../../shared/container/index.js";
6
+ import { TYPES } from "../../shared/services/types.js";
5
7
  export function useCommandHandlers(context) {
6
8
  const { activeTab, selected, selectedRunIndex, selectedTask, selectedProjectEntity, tasks, projects, currentProjectId, setCloneMode, setEditTarget, setPendingTaskSelection, setEditTask, setEditProject, setActiveTab, setConfirmState, setCommandsBrowserOpen, setSearchValue, setSearchState, setFilters, setSort, setCurrentProjectId, setProjectFilters, setProjectSelectedIndex, setDebugMode, setExportModal, push, pop, refresh, refreshTasks, refreshProjects, pushToast, loopService, taskService, projectService, exportService, runAction, handleOpenRunLog, } = context;
7
9
  const commandHandlers = {
@@ -128,6 +130,36 @@ export function useCommandHandlers(context) {
128
130
  const baseUrl = `http://127.0.0.1:${port}`;
129
131
  pushToast("info", `API: ${baseUrl} | Swagger: ${baseUrl}/api/docs | OpenAPI: ${baseUrl}/api/openapi.json`);
130
132
  },
133
+ "toggle-api": async () => {
134
+ try {
135
+ const settingsService = container.get(TYPES.SettingsService);
136
+ const current = await settingsService.getHttpApiEnabled();
137
+ await settingsService.setHttpApiEnabled(!current);
138
+ pushToast("success", t(!current ? "board.toastApiEnabled" : "board.toastApiDisabled"));
139
+ }
140
+ catch (e) {
141
+ pushToast("error", t("board.toastApiToggleError", { message: e.message }));
142
+ }
143
+ },
144
+ mcp: () => {
145
+ const transport = process.env.LOOP_CLI_MCP_TRANSPORT ?? "sse";
146
+ const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
147
+ const info = transport === "stdio"
148
+ ? `MCP server on stdio. To use SSE (default), restart without LOOP_CLI_MCP_TRANSPORT=stdio`
149
+ : `MCP: connect your client to http://127.0.0.1:${port}/sse`;
150
+ pushToast("info", info);
151
+ },
152
+ "toggle-mcp": async () => {
153
+ try {
154
+ const settingsService = container.get(TYPES.SettingsService);
155
+ const current = await settingsService.getMcpApiEnabled();
156
+ await settingsService.setMcpApiEnabled(!current);
157
+ pushToast("success", t(!current ? "board.toastMcpEnabled" : "board.toastMcpDisabled"));
158
+ }
159
+ catch (e) {
160
+ pushToast("error", t("board.toastMcpToggleError", { message: e.message }));
161
+ }
162
+ },
131
163
  status: () => { pushToast("info", `Command "status" coming soon`); },
132
164
  export: () => {
133
165
  exportService.exportConfig()
@@ -46,7 +46,6 @@ export function useGlobalShortcuts(context) {
46
46
  const globalShortcuts = {
47
47
  p: () => { setCommandsBrowserOpen(true); },
48
48
  b: () => handleCommand("debug"),
49
- g: () => handleCommand("api"),
50
49
  x: () => handleCommand("export"),
51
50
  i: () => handleCommand("import"),
52
51
  y: () => handleCommand("status"),
@@ -15,6 +15,7 @@ function createInitialValues(editTarget, currentProjectId) {
15
15
  runNow: "y",
16
16
  maxRuns: "",
17
17
  project: currentProjectId,
18
+ context: "",
18
19
  };
19
20
  }
20
21
  return {
@@ -27,6 +28,7 @@ function createInitialValues(editTarget, currentProjectId) {
27
28
  runNow: "y",
28
29
  maxRuns: editTarget.maxRuns?.toString() ?? "",
29
30
  project: editTarget.projectId ?? "default",
31
+ context: editTarget.context ? JSON.stringify(editTarget.context) : "",
30
32
  };
31
33
  }
32
34
  export function FormRouter(props) {
@@ -15,7 +15,7 @@ export function ExportModal(props) {
15
15
  const [scrollOffset, setScrollOffset] = useState(0);
16
16
  useInput((input, key) => {
17
17
  // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
18
- // before the escape checkthe leading ESC trips key.escape and would
18
+ // before the escape check the leading ESC trips key.escape and would
19
19
  // close the modal on a right-click paste.
20
20
  if (input.includes("\x1b[200~")) {
21
21
  return;
@@ -96,7 +96,7 @@ export function joinCommandLines(text) {
96
96
  // - Segment ending with \: backslash consumed, joins to next with NO added space
97
97
  // - Segment NOT ending with \: joins to next with a single space
98
98
  // Leading whitespace on each line is trimmed (indentation); trailing whitespace
99
- // before \ is preserved (it's part of the contente.g. separates tokens).
99
+ // before \ is preserved (it's part of the content e.g. separates tokens).
100
100
  let result = "";
101
101
  let prevContinued = false; // true if previous segment ended with \
102
102
  for (const seg of segments) {
@@ -129,20 +129,24 @@ export function buildLoopOptions(intervalHuman, input = {}) {
129
129
  if (!description) {
130
130
  throw new Error(t("errors.descriptionEmpty"));
131
131
  }
132
+ const parsedInterval = parseDuration(intervalHuman);
133
+ const isManual = parsedInterval === 0;
134
+ const normalizedIntervalHuman = isManual ? "manual" : intervalHuman;
132
135
  return {
133
- intervalHuman,
136
+ intervalHuman: normalizedIntervalHuman,
134
137
  options: {
135
- interval: parseDuration(intervalHuman),
138
+ interval: parsedInterval,
136
139
  taskId,
137
140
  command,
138
141
  commandArgs,
139
142
  cwd: input.cwd ?? "",
140
- immediate: input.now ?? false,
143
+ immediate: isManual ? false : (input.now ?? false),
141
144
  maxRuns: parseMaxRuns(input.maxRuns),
142
145
  verbose: input.verbose ?? false,
143
146
  description,
144
147
  projectId: input.projectId ?? "default",
145
148
  offset: input.offset ?? null,
149
+ context: input.context,
146
150
  },
147
151
  };
148
152
  }
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { platform } from "node:os";
3
3
  /**
4
- * Write text to the system clipboard. Never throwsreturns false if no
4
+ * Write text to the system clipboard. Never throws returns false if no
5
5
  * clipboard tool is available (e.g. a headless SSH box without xclip/xsel).
6
6
  *
7
7
  * Linux toolchain tried in order: xclip → xsel → wl-copy (Wayland) →
@@ -28,7 +28,7 @@ export function copyToClipboard(text) {
28
28
  return false;
29
29
  }
30
30
  }
31
- // Linux / BSD: try each clipboard tool in order. All calls are guarded
31
+ // Linux / BSD: try each clipboard tool in order. All calls are guarded
32
32
  // a missing binary must never throw, since callers invoke this from
33
33
  // synchronous keypress handlers (an uncaught throw here kills the app).
34
34
  const tries = [
@@ -30,6 +30,9 @@ export function tasksJson() {
30
30
  export function projectsJson() {
31
31
  return path.join(getDataDir(), "projects.json");
32
32
  }
33
+ export function settingsJson() {
34
+ return path.join(getDataDir(), "settings.json");
35
+ }
33
36
  export function logFile(id) {
34
37
  return path.join(getLogsDir(), `${id}.log`);
35
38
  }
@@ -5,12 +5,14 @@ import { IpcTaskService } from "../services/task-service.js";
5
5
  import { IpcProjectService } from "../services/project-service.js";
6
6
  import { IpcLogService } from "../services/log-service.js";
7
7
  import { IpcExportService } from "../services/export-service.js";
8
+ import { IpcSettingsService } from "../services/settings-service.js";
8
9
  export function createContainer() {
9
10
  const container = new Container();
10
11
  container.bind(TYPES.LoopService).to(IpcLoopService);
11
12
  container.bind(TYPES.TaskService).to(IpcTaskService);
12
13
  container.bind(TYPES.ProjectService).to(IpcProjectService);
13
14
  container.bind(TYPES.LogService).to(IpcLogService);
15
+ container.bind(TYPES.SettingsService).to(IpcSettingsService);
14
16
  container.bind(TYPES.ExportService).toDynamicValue(() => {
15
17
  const loopService = container.get(TYPES.LoopService);
16
18
  const taskService = container.get(TYPES.TaskService);
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useInject } from "./useInject.js";
3
+ import { TYPES } from "../services/types.js";
4
+ import { POLL_MS } from "../config/constants.js";
5
+ export function useDaemonSettings() {
6
+ const settingsService = useInject(TYPES.SettingsService);
7
+ const [state, setState] = useState({
8
+ httpApiEnabled: false,
9
+ mcpApiEnabled: false,
10
+ reachable: false,
11
+ });
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+ const poll = async () => {
15
+ try {
16
+ const settings = await settingsService.getSettings();
17
+ if (!cancelled) {
18
+ setState({
19
+ httpApiEnabled: settings.httpApiEnabled,
20
+ mcpApiEnabled: settings.mcpApiEnabled,
21
+ reachable: true,
22
+ });
23
+ }
24
+ }
25
+ catch {
26
+ if (!cancelled) {
27
+ setState((prev) => ({ ...prev, reachable: false }));
28
+ }
29
+ }
30
+ };
31
+ void poll();
32
+ const timer = setInterval(() => void poll(), POLL_MS);
33
+ return () => {
34
+ cancelled = true;
35
+ clearInterval(timer);
36
+ };
37
+ }, [settingsService]);
38
+ return state;
39
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useRef, useCallback } from "react";
2
2
  /**
3
- * Pure undo/redo stack logicexported for direct unit testing
3
+ * Pure undo/redo stack logic exported for direct unit testing
4
4
  * without needing React DOM or renderHook.
5
5
  */
6
6
  export class UndoRedoStack {
@@ -4,7 +4,7 @@
4
4
  "cli.startDescription": "Start the background daemon (restores persisted loops)",
5
5
  "cli.newDescription": "Create a new loop in the background",
6
6
  "cli.runDescription": "Run a loop in the foreground",
7
- "cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h)",
7
+ "cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h, or manual)",
8
8
  "cli.argCommand": "Command to execute",
9
9
  "cli.optNow": "Run immediately before waiting",
10
10
  "cli.optMaxRuns": "Stop after N executions",
@@ -29,6 +29,7 @@
29
29
  "cli.statusInterval": " Interval: {interval} ({duration})",
30
30
  "cli.statusStatus": " Status: {status}",
31
31
  "cli.statusRuns": " Runs: {runs} / {maxRuns}",
32
+ "cli.statusSilentChains": " Silent: {count} silent-chain runs",
32
33
  "cli.statusCreated": " Created: {created}",
33
34
  "cli.statusLastRun": " Last run: {lastRun} {exit} {duration}",
34
35
  "cli.statusNextRun": " Next run: {nextRun}",
@@ -47,6 +48,8 @@
47
48
  "cli.projectColorDescription": "Change a project's color",
48
49
  "cli.projectDeleteDescription": "Delete a project (loops move to Default)",
49
50
  "cli.optProjectColor": "Project color (name or #hex)",
51
+ "cli.optProjectDirectory": "Project working directory",
52
+ "cli.optProjectGithubSource": "GitHub repository in owner/repo format",
50
53
  "cli.projectArgName": "Project name",
51
54
  "cli.projectArgNewName": "New project name",
52
55
  "cli.projectArgIdOrName": "Project id or name",
@@ -83,7 +86,7 @@
83
86
  "loop.debugDuration": "Duration: {duration}",
84
87
  "loop.nextRun": "Next run in {duration} (at {time})",
85
88
  "errors.durationEmpty": "Duration cannot be empty",
86
- "errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w",
89
+ "errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w, or manual",
87
90
  "errors.durationNotPositive": "Duration must be positive, got: \"{input}\"",
88
91
  "errors.maxRunsInvalid": "--max-runs must be a positive integer",
89
92
  "errors.unbalancedQuote": "Unbalanced quote in command",
@@ -117,6 +120,7 @@
117
120
  "format.daysAhead": "{days}d",
118
121
  "format.timingPaused": "paused",
119
122
  "format.timingIdle": "not scheduled",
123
+ "format.durationManual": "manual trigger only",
120
124
  "format.timingNext": "in {timeAgo}",
121
125
  "format.timingLast": "last {timeAgo}",
122
126
  "format.timingNew": "new",
@@ -143,6 +147,7 @@
143
147
  "board.headerSince": "SINCE",
144
148
  "board.headerRuns": "RUNS",
145
149
  "board.headerSkipped": "SKIP",
150
+ "board.headerSilent": "SILENT",
146
151
  "board.headerStatus": "STATUS",
147
152
  "board.headerDescription": "DESCRIPTION",
148
153
  "board.headerTiming": "TIMING",
@@ -173,6 +178,7 @@
173
178
  "board.fieldLastRun": "Last run:",
174
179
  "board.fieldLastExit": "Last exit:",
175
180
  "board.fieldNextRun": "Next run:",
181
+ "board.fieldSilentChains": "Silent:",
176
182
  "board.fieldPid": "PID:",
177
183
  "board.inherit": "(inherit)",
178
184
  "board.unlimited": "∞",
@@ -209,6 +215,7 @@
209
215
  "board.detailFieldLastExit": "Last exit: ",
210
216
  "board.detailFieldNextRun": "Next run: ",
211
217
  "board.detailFieldPid": "PID: ",
218
+ "board.detailFieldSilentChains": "Silent: ",
212
219
  "board.detailSummaryStatus": "status",
213
220
  "board.detailSummaryTiming": "next",
214
221
  "board.detailSummaryInterval": "every",
@@ -257,7 +264,7 @@
257
264
  "board.taskCreateTitle": " New Task ",
258
265
  "board.taskEditTitle": " Edit Task ",
259
266
  "board.hintTaskMode": "type a command inline, or pick an existing task",
260
- "board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d",
267
+ "board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d, or manual",
261
268
  "board.hintCommand": "Full command line. Quote args with spaces",
262
269
  "board.hintDescription": "Short label shown in the list. Blank uses the command",
263
270
  "board.hintCwd": "Leave blank to inherit from project directory, or set a specific path",
@@ -399,6 +406,7 @@
399
406
  "board.taskFieldId": "ID:",
400
407
  "board.taskFieldCommand": "Command:",
401
408
  "board.taskFieldChain": "Chain:",
409
+ "board.taskFieldSilent": "Silent:",
402
410
  "board.taskFieldCreated": "Created:",
403
411
  "board.taskHeaderName": "NAME",
404
412
  "board.taskHeaderCommand": "COMMAND",
@@ -414,6 +422,9 @@
414
422
  "board.selectedTask": "Task: {name}",
415
423
  "board.taskChainsNone": "-",
416
424
  "board.taskChainsFormat": "✓:{success} ✗:{failure}",
425
+ "board.silentChainCount": "{count} silent-chain runs",
426
+ "board.silentChainYes": "yes",
427
+ "board.silentChainNo": "no",
417
428
  "board.logModalCopied": "Log copied to clipboard",
418
429
  "project.selectProject": " Select Project ",
419
430
  "project.projectsLabel": "Projects",
@@ -450,6 +461,8 @@
450
461
  "project.wizard.colorHint": "Choose a color to identify this project",
451
462
  "project.wizard.directoryPrompt": "Working directory? (optional)",
452
463
  "project.wizard.directoryHint": "Default directory for loops in this project. Leave blank to inherit",
464
+ "project.wizard.githubSourcePrompt": "GitHub Source? (optional)",
465
+ "project.wizard.githubSourceHint": "Repository in owner/repo format, e.g. CKGrafico/loop-task",
453
466
  "project.error.updateFailed": "Failed to update project",
454
467
  "project.error.deleteFailed": "Failed to delete project",
455
468
  "project.toastCreated": "Project \"{name}\" created",
@@ -509,6 +522,15 @@
509
522
  "cmd.show": "Show a hidden panel",
510
523
  "cmd.hide": "Hide a panel",
511
524
  "cmd.api": "API endpoints info",
525
+ "cmd.toggleApi": "Toggle HTTP API on/off",
526
+ "cmd.mcp": "MCP server info",
527
+ "cmd.toggleMcp": "Toggle MCP server on/off",
528
+ "board.toastApiEnabled": "HTTP API enabled",
529
+ "board.toastApiDisabled": "HTTP API disabled",
530
+ "board.toastApiToggleError": "Failed to toggle API: {message}",
531
+ "board.toastMcpEnabled": "MCP server enabled",
532
+ "board.toastMcpDisabled": "MCP server disabled",
533
+ "board.toastMcpToggleError": "Failed to toggle MCP: {message}",
512
534
  "cmd.status": "Daemon status info (Soon...)",
513
535
  "cmd.filterStatus": "Filter by status",
514
536
  "cmd.sort": "Order sort by field",
@@ -574,7 +596,7 @@
574
596
  "wizard.newTask": "New task",
575
597
  "wizard.stepOf": "Step {current} of {total}",
576
598
  "wizard.intervalPrompt": "How often should it run?",
577
- "wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d",
599
+ "wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d, or manual for trigger-only",
578
600
  "wizard.commandPrompt": "What command should run?",
579
601
  "wizard.commandHint": "Full command line. Quote args with spaces",
580
602
  "wizard.taskModePrompt": "Inline command or existing task?",
@@ -596,6 +618,11 @@
596
618
  "wizard.taskCommandPrompt": "What command should this task run?",
597
619
  "wizard.onSuccessPrompt": "On success, chain to? (optional)",
598
620
  "wizard.onFailurePrompt": "On failure, chain to? (optional)",
621
+ "wizard.silentChainPrompt": "Silent chain? (suppress log output)",
622
+ "wizard.silentChainHint": "Skip logging for this task when chained",
623
+ "wizard.contextPrompt": "Initial context? (optional)",
624
+ "wizard.contextHint": "JSON object with string values, e.g. {\"env\":\"staging\"}",
625
+ "wizard.contextInvalid": "Invalid context: must be a flat JSON object with non-nested, non-array values",
599
626
  "wizard.created": "Created",
600
627
  "wizard.filled": "{filled}/{total} filled",
601
628
  "wizard.footerHints": "tab next . ctrl+s save . esc cancel",
@@ -14,14 +14,14 @@ let IpcProjectService = class IpcProjectService {
14
14
  throw new Error(response.message);
15
15
  return response.data;
16
16
  }
17
- async create(name, color, directory) {
18
- const response = await sendRequest({ type: "project-create", payload: { name, color, directory } });
17
+ async create(name, color, directory, githubSource) {
18
+ const response = await sendRequest({ type: "project-create", payload: { name, color, directory, githubSource } });
19
19
  if (response.type !== "ok")
20
20
  throw new Error(response.message);
21
21
  return response.data;
22
22
  }
23
- async update(id, name, color, directory) {
24
- const response = await sendRequest({ type: "project-update", payload: { id, name, color, directory } });
23
+ async update(id, name, color, directory, githubSource) {
24
+ const response = await sendRequest({ type: "project-update", payload: { id, name, color, directory, githubSource } });
25
25
  if (response.type !== "ok") {
26
26
  throw new Error(response.message ?? t("project.error.updateFailed"));
27
27
  }
@@ -0,0 +1,49 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { injectable } from "inversify";
8
+ import { sendRequest } from "../../client/ipc.js";
9
+ let IpcSettingsService = class IpcSettingsService {
10
+ async getSettings() {
11
+ const response = await sendRequest({ type: "settings-get" });
12
+ if (response.type !== "ok") {
13
+ throw new Error(response.message ?? "Failed to get settings");
14
+ }
15
+ return response.data;
16
+ }
17
+ async getHttpApiEnabled() {
18
+ const response = await sendRequest({ type: "settings-get" });
19
+ if (response.type !== "ok") {
20
+ throw new Error(response.message ?? "Failed to get settings");
21
+ }
22
+ return response.data.httpApiEnabled;
23
+ }
24
+ async setHttpApiEnabled(enabled) {
25
+ const response = await sendRequest({ type: "settings-set", settings: { httpApiEnabled: enabled } });
26
+ if (response.type !== "ok") {
27
+ throw new Error(response.message ?? "Failed to set settings");
28
+ }
29
+ return response.data;
30
+ }
31
+ async getMcpApiEnabled() {
32
+ const response = await sendRequest({ type: "settings-get" });
33
+ if (response.type !== "ok") {
34
+ throw new Error(response.message ?? "Failed to get settings");
35
+ }
36
+ return response.data.mcpApiEnabled;
37
+ }
38
+ async setMcpApiEnabled(enabled) {
39
+ const response = await sendRequest({ type: "settings-set", settings: { mcpApiEnabled: enabled } });
40
+ if (response.type !== "ok") {
41
+ throw new Error(response.message ?? "Failed to set settings");
42
+ }
43
+ return response.data;
44
+ }
45
+ };
46
+ IpcSettingsService = __decorate([
47
+ injectable()
48
+ ], IpcSettingsService);
49
+ export { IpcSettingsService };
@@ -4,4 +4,5 @@ export const TYPES = {
4
4
  ProjectService: Symbol.for("ProjectService"),
5
5
  LogService: Symbol.for("LogService"),
6
6
  ExportService: Symbol.for("ExportService"),
7
+ SettingsService: Symbol.for("SettingsService"),
7
8
  };
@@ -23,7 +23,7 @@ export function FocusableInput(props) {
23
23
  }
24
24
  if (key.ctrl || key.escape)
25
25
  return;
26
- // Multi-char containing CR/LF with no bracketed markersignore
26
+ // Multi-char containing CR/LF with no bracketed markers ignore
27
27
  if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
28
28
  return;
29
29
  if (key.return) {
@@ -7,7 +7,7 @@ const MAX_VISIBLE = 10;
7
7
  /**
8
8
  * Filterable modal picker: type to narrow, up/down to navigate, enter to
9
9
  * select, esc to cancel. The single interaction pattern for every enumerated
10
- * field in the boarda fixed set of two options and a list of dozens both
10
+ * field in the board a fixed set of two options and a list of dozens both
11
11
  * use this, so users only ever learn one gesture for "change this value".
12
12
  */
13
13
  export function SelectModal(props) {
@@ -66,6 +66,8 @@ export function statusColor(status) {
66
66
  return STATUS_COLORS[status] ?? "#ffffff";
67
67
  }
68
68
  export function timingLabel(loop) {
69
+ if (loop.interval === 0)
70
+ return t("format.durationManual");
69
71
  if (loop.status === "paused")
70
72
  return t("format.timingPaused");
71
73
  if (loop.status === "idle")
@@ -10,7 +10,7 @@ function scanTokens(line) {
10
10
  let i = 0;
11
11
  const len = line.length;
12
12
  while (i < len) {
13
- // Whitespace runpreserve it as a token
13
+ // Whitespace run preserve it as a token
14
14
  if (line[i] === " " || line[i] === "\t") {
15
15
  let j = i;
16
16
  while (j < len && (line[j] === " " || line[j] === "\t"))
@@ -58,7 +58,7 @@ function scanTokens(line) {
58
58
  i = j;
59
59
  continue;
60
60
  }
61
- // Unquoted wordconsume until whitespace or operator
61
+ // Unquoted word consume until whitespace or operator
62
62
  let j = i;
63
63
  while (j < len) {
64
64
  if (line[j] === " " || line[j] === "\t")
@@ -90,7 +90,7 @@ function scanTokens(line) {
90
90
  }
91
91
  /**
92
92
  * Tokenize a command line for syntax highlighting purposes.
93
- * This is a cosmetic tokenizerthe real parsing for execution
93
+ * This is a cosmetic tokenizer the real parsing for execution
94
94
  * lives in src/loop-config.ts parseCommandLine.
95
95
  *
96
96
  * Whitespace is not returned (use highlightSegments for rendering
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
4
  import { darkTheme as theme } from "../../shared/ui/theme.js";
@@ -27,15 +27,28 @@ function daemonText(status) {
27
27
  case "error": return "offline";
28
28
  }
29
29
  }
30
+ function ServiceStatus({ label, enabled, online }) {
31
+ // Unknown until the daemon is reachable and reports the setting.
32
+ const known = online && enabled !== undefined;
33
+ const on = known && enabled === true;
34
+ const color = !known
35
+ ? theme.text.muted
36
+ : on
37
+ ? theme.semantic.success
38
+ : theme.semantic.danger;
39
+ const symbol = !known ? "\u25CB" : on ? "\u25CF" : "\u25CB";
40
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: label }), _jsx(Text, { color: color, children: symbol })] }));
41
+ }
30
42
  export function Header(props) {
31
43
  const { stdout } = useStdout();
32
44
  const width = stdout?.columns ?? 80;
33
45
  const compact = width < HEADER_COMPACT_WIDTH;
46
+ const online = props.daemonStatus === "connected";
34
47
  const entries = [
35
48
  { label: t("board.runningLabel"), value: props.counts.running, color: theme.semantic.success },
36
49
  { label: t("board.waitingLabel"), value: props.counts.waiting, color: theme.accent.loop },
37
50
  { label: t("board.pausedLabel"), value: props.counts.paused, color: theme.semantic.warning },
38
51
  { label: t("board.idleLabel"), value: props.counts.idle, color: theme.semantic.idle },
39
52
  ];
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.accent.brand, bold: true, children: t("board.appName") }), _jsx(Text, { color: theme.text.muted, children: VERSION }), _jsx(Text, { color: daemonColor(props.daemonStatus), children: daemonSymbol(props.daemonStatus) }), _jsx(Text, { color: theme.text.secondary, children: daemonText(props.daemonStatus) }), !compact && entries.map((e) => e.value > 0 ? (_jsxs(React.Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: e.label }), _jsx(Text, { color: e.color, children: e.value })] }, e.label)) : null)] }), _jsx(TabBar, { activeTab: props.activeTab, onTabChange: props.onTabChange, counts: props.tabCounts })] }), _jsx(Box, { children: _jsx(Text, { color: theme.border.default, children: "\u2500".repeat(width) }) })] }));
53
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.accent.brand, bold: true, children: t("board.appName") }), _jsx(Text, { color: theme.text.muted, children: VERSION }), _jsx(Text, { color: daemonColor(props.daemonStatus), children: daemonSymbol(props.daemonStatus) }), _jsx(Text, { color: theme.text.secondary, children: daemonText(props.daemonStatus) }), _jsx(ServiceStatus, { label: "api", enabled: props.httpApiEnabled, online: online }), _jsx(ServiceStatus, { label: "mcp", enabled: props.mcpApiEnabled, online: online }), !compact && entries.map((e) => e.value > 0 ? (_jsxs(React.Fragment, { children: [_jsx(Text, { color: theme.text.muted, children: e.label }), _jsx(Text, { color: e.color, children: e.value })] }, e.label)) : null)] }), _jsx(TabBar, { activeTab: props.activeTab, onTabChange: props.onTabChange, counts: props.tabCounts })] }), _jsx(Box, { children: _jsx(Text, { color: theme.border.default, children: "\u2500".repeat(width) }) })] }));
41
54
  }
@@ -9,6 +9,7 @@ const DESC_WIDTH = 32;
9
9
  const SINCE_WIDTH = 13;
10
10
  const RUNS_WIDTH = 4;
11
11
  const SKIPPED_WIDTH = 4;
12
+ const SILENT_WIDTH = 4;
12
13
  const STATUS_WIDTH = 8;
13
14
  const COL_GAP = 1;
14
15
  const LIMIT = 15;
@@ -58,9 +59,9 @@ export function Navigator(props) {
58
59
  : isSelected
59
60
  ? theme.text.inverse
60
61
  : projectColor(loop);
61
- return (_jsxs(_Fragment, { children: [_jsx(Text, { color: dotColor, children: dotChar }), _jsx(Text, { color: fg, children: desc.padEnd(DESC_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: since.padEnd(SINCE_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: String(loop.runCount).padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: fg, children: String(loop.skippedCount).padStart(SKIPPED_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: isSelected ? theme.text.inverse : sColor, children: sLabel.padEnd(STATUS_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: timing }), loop.status === "running" ? (_jsxs(Text, { color: theme.semantic.success, children: [" ", _jsx(Spinner, { type: "dots" })] })) : null] }));
62
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: dotColor, children: dotChar }), _jsx(Text, { color: fg, children: desc.padEnd(DESC_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: since.padEnd(SINCE_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: String(loop.runCount).padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: fg, children: String(loop.skippedCount).padStart(SKIPPED_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: fg, children: String(loop.silentChainCount ?? 0).padStart(SILENT_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: isSelected ? theme.text.inverse : sColor, children: sLabel.padEnd(STATUS_WIDTH + COL_GAP) }), _jsx(Text, { color: fg, children: timing }), loop.status === "running" ? (_jsxs(Text, { color: theme.semantic.success, children: [" ", _jsx(Spinner, { type: "dots" })] })) : null] }));
62
63
  }
63
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.noMatch") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.headerDescription").padEnd(DESC_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerSince").padEnd(SINCE_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerRuns").padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerSkipped").padStart(SKIPPED_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerStatus").padEnd(STATUS_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerTiming") })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { selectedIndex: selectedIndex, height: LIMIT, children: visible.map((loop, i) => {
64
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.noMatch") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.headerDescription").padEnd(DESC_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerSince").padEnd(SINCE_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerRuns").padStart(RUNS_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerSkipped").padStart(SKIPPED_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerSilent").padStart(SILENT_WIDTH) + " ".repeat(COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerStatus").padEnd(STATUS_WIDTH + COL_GAP) }), _jsx(Text, { color: theme.text.muted, children: t("board.headerTiming") })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { selectedIndex: selectedIndex, height: LIMIT, children: visible.map((loop, i) => {
64
65
  const isSelected = i === selectedIndex;
65
66
  const indicator = isSelected ? "\u203a " : " ";
66
67
  return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.active : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: isSelected ? theme.text.inverse : theme.text.primary, children: indicator }), renderLoop(loop, isSelected)] }, i));
@@ -53,8 +53,9 @@ export function TaskNavigator(props) {
53
53
  ? cmd.slice(0, COMMAND_WIDTH - 3) + "..."
54
54
  : cmd.padEnd(COMMAND_WIDTH);
55
55
  const chains = chainsLabel(task);
56
+ const silent = task.silentChain ? " [s]" : "";
56
57
  const fg = isSelected ? theme.text.inverse : theme.text.primary;
57
- return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.activeTask : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: fg, children: name }), _jsx(Text, { color: fg, children: cmdDisplay }), _jsx(Text, { color: fg, children: chains })] }));
58
+ return (_jsxs(Box, { backgroundColor: isSelected ? (isFocused && navActive ? theme.bg.activeTask : isFocused ? theme.bg.hover : undefined) : undefined, children: [_jsx(Text, { color: fg, children: name }), _jsx(Text, { color: fg, children: cmdDisplay }), _jsxs(Text, { color: fg, children: [chains, silent] })] }));
58
59
  }
59
60
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: title }) }), visible.length === 0 ? (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.taskBrowserEmpty") }) })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: theme.text.muted, children: " " }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderName").padEnd(NAME_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderCommand").padEnd(COMMAND_WIDTH) }), _jsx(Text, { color: theme.text.muted, children: t("board.taskHeaderChains") })] }), _jsx(Box, { paddingLeft: 1, children: _jsx(ScrollList, { height: LIMIT, selectedIndex: selectedIndex, scrollAlignment: "auto", children: visible.map((task, i) => (_jsx(React.Fragment, { children: renderTask(task, i) }, task.id))) }) })] }))] }));
60
61
  }
@@ -75,7 +76,7 @@ export function TaskInspector(props) {
75
76
  };
76
77
  const onSuccess = task.onSuccessTaskId ? resolveName(task.onSuccessTaskId) ?? task.onSuccessTaskId : t("board.taskNone");
77
78
  const onFailure = task.onFailureTaskId ? resolveName(task.onFailureTaskId) ?? task.onFailureTaskId : t("board.taskNone");
78
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(InspectorField, { label: t("board.fieldId"), children: _jsx(Text, { color: theme.text.primary, children: task.id }) }), _jsx(InspectorField, { label: t("board.taskLabelName") + ": ", children: _jsx(Text, { color: theme.text.primary, children: task.name }) }), _jsx(InspectorField, { label: t("board.taskLabelCommand") + ": ", children: _jsx(Text, { color: theme.text.primary, children: cmd }) }), _jsx(InspectorField, { label: t("board.taskLabelOnSuccess") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onSuccess }) }), _jsx(InspectorField, { label: t("board.taskLabelOnFailure") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onFailure }) })] })] }));
79
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.border.default, flexDirection: "column", children: [_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { color: theme.text.muted, children: t("board.inspectorTitle") }) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(InspectorField, { label: t("board.fieldId"), children: _jsx(Text, { color: theme.text.primary, children: task.id }) }), _jsx(InspectorField, { label: t("board.taskLabelName") + ": ", children: _jsx(Text, { color: theme.text.primary, children: task.name }) }), _jsx(InspectorField, { label: t("board.taskLabelCommand") + ": ", children: _jsx(Text, { color: theme.text.primary, children: cmd }) }), _jsx(InspectorField, { label: t("board.taskLabelOnSuccess") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onSuccess }) }), _jsx(InspectorField, { label: t("board.taskLabelOnFailure") + ": ", children: _jsx(Text, { color: theme.text.primary, children: onFailure }) }), _jsx(InspectorField, { label: t("board.taskFieldSilent") + ": ", children: _jsx(Text, { color: task.silentChain ? theme.semantic.warning : theme.text.muted, children: task.silentChain ? t("board.silentChainYes") : t("board.silentChainNo") }) })] })] }));
79
80
  }
80
81
  export function TaskActionButtons(props) {
81
82
  const { task, selectable, onAction } = props;