loop-task 2.0.15 → 2.1.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.
@@ -38,7 +38,7 @@ const LOOP_FIELDS = [
38
38
  { name: "remainingDelayMs", type: "number", nullable: true },
39
39
  { name: "pid", type: "number", nullable: false },
40
40
  { name: "maxRunsReached", type: "boolean", nullable: false },
41
- { name: "runHistory", type: "array", nullable: false },
41
+ { name: "runHistory", type: "array", nullable: true },
42
42
  { name: "skippedCount", type: "number", nullable: false },
43
43
  { name: "projectId", type: "string", nullable: false },
44
44
  { name: "offset", type: "number", nullable: true },
@@ -9,8 +9,12 @@ function backupFilePath(filePath) {
9
9
  export function atomicImportWrite(loops, tasks, projects) {
10
10
  const dataDir = getDataDir();
11
11
  fs.mkdirSync(dataDir, { recursive: true });
12
+ const normalizedLoops = loops.map((loop) => ({
13
+ ...loop,
14
+ runHistory: Array.isArray(loop.runHistory) ? loop.runHistory : [],
15
+ }));
12
16
  const contents = {
13
- "loops.json": JSON.stringify(loops, null, 2),
17
+ "loops.json": JSON.stringify(normalizedLoops, null, 2),
14
18
  "tasks.json": JSON.stringify(tasks, null, 2),
15
19
  "projects.json": JSON.stringify(projects, null, 2),
16
20
  };
package/dist/cli.js CHANGED
@@ -206,10 +206,11 @@ program
206
206
  sendRequest({ type: "task-list" }),
207
207
  sendRequest({ type: "project-list" }),
208
208
  ]);
209
+ const loops = loopsRes.type === "ok" ? loopsRes.data : [];
209
210
  const exportData = {
210
211
  version: 2,
211
212
  exportedAt: new Date().toISOString(),
212
- loops: loopsRes.type === "ok" ? loopsRes.data : [],
213
+ loops: loops.map(({ runHistory: _ignored, ...rest }) => rest),
213
214
  tasks: tasksRes.type === "ok" ? tasksRes.data : [],
214
215
  projects: projectsRes.type === "ok" ? projectsRes.data : [],
215
216
  };
@@ -13,7 +13,6 @@ export const LOG_TAIL_DEFAULT = 50;
13
13
  export const BOARD_BREAKPOINT_WIDTH = 80;
14
14
  export const HEADER_COMPACT_WIDTH = 60;
15
15
  export const SEARCH_SELECT_HEIGHT = 6;
16
- // ── Clipboard / bracketed paste ─────────────────────────────────────
17
16
  // DECSET 2004: terminal wraps pasted text in ESC[200~ ... ESC[201~ so the
18
17
  // app can insert it wholesale instead of processing each char as a keypress.
19
18
  export const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
@@ -35,30 +34,24 @@ export const ENTITY_COLORS = {
35
34
  task: "#a78bfa", // purple
36
35
  project: "#34d399", // green
37
36
  };
38
- // ── Wizard step counts ──────────────────────────────────────────────
39
37
  export const WIZARD_LOOP_REQUIRED_STEPS = 3;
40
38
  export const WIZARD_LOOP_TOTAL_STEPS = 7;
41
39
  export const WIZARD_TASK_REQUIRED_STEPS = 2;
42
40
  export const WIZARD_TASK_TOTAL_STEPS = 4;
43
41
  export const WIZARD_PROJECT_REQUIRED_STEPS = 1;
44
42
  export const WIZARD_PROJECT_TOTAL_STEPS = 2;
45
- // ── Command tiers ───────────────────────────────────────────────────
46
43
  export const COMMAND_TIER_ACTION = "action";
47
44
  export const COMMAND_TIER_CONFIRM = "confirm";
48
45
  export const COMMAND_TIER_GLOBAL = "global";
49
- // ── Command categories ─────────────────────────────────────────────
50
46
  export const COMMAND_CATEGORY_GLOBAL = "global";
51
47
  export const COMMAND_CATEGORY_FILTERS = "filters";
52
48
  export const COMMAND_CATEGORY_LOOP = "loop";
53
49
  export const COMMAND_CATEGORY_TASK = "task";
54
50
  export const COMMAND_CATEGORY_PROJECT = "project";
55
- // ── Confirm keywords ────────────────────────────────────────────────
56
51
  export const CONFIRM_YES = "yes";
57
52
  export const CONFIRM_CANCEL = "cancel";
58
- // ── Panel focus types ───────────────────────────────────────────────
59
53
  export const PANEL_LEFT = "left";
60
54
  export const PANEL_RIGHT = "right";
61
- // ── Command builder templates ──────────────────────────────────────
62
55
  export const COMMAND_TEMPLATES = [
63
56
  { label: "npm run", command: "npm", args: "run" },
64
57
  { label: "npm test", command: "npm", args: "test" },
@@ -71,19 +64,16 @@ export const COMMAND_TEMPLATES = [
71
64
  { label: "make", command: "make", args: "" },
72
65
  { label: "shell script", command: "bash", args: "./script.sh" },
73
66
  ];
74
- // ── Command input constants ─────────────────────────────────────────
75
67
  export const COMMAND_INPUT_HEIGHT = 6;
76
68
  export const COMMAND_INPUT_DROPDOWN_MAX_VISIBLE = 6;
77
- // ── HTTP API server ─────────────────────────────────────────────────
78
69
  export const HTTP_API_PORT = 8845;
79
70
  export const HTTP_API_HOST = "127.0.0.1";
80
- // ── Ctrl shortcut hints (for display) ───────────────────────────────
81
71
  export const EXPORT_MAX_PREVIEW_LINES = 200;
82
72
  export const CTRL_SHORTCUT_EDIT = "Ctrl+E";
83
73
  export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
84
- // ── Code editor constants ──────────────────────────────────────────
85
74
  export const CODE_EDITOR_MAX_VISIBLE = 2;
86
- export const CODE_EDITOR_MODAL_HEIGHT = 20;
75
+ export const CODE_EDITOR_MODAL_HEIGHT = 40;
76
+ export const CODE_EDITOR_MODAL_WIDTH = 120;
87
77
  export const CODE_EDITOR_UNDO_LIMIT = 50;
88
78
  export const CODE_EDITOR_SYNTAX_COLORS = {
89
79
  flag: "#38bdf8",
@@ -330,14 +330,39 @@ export class LoopController extends EventEmitter {
330
330
  });
331
331
  this.runAbortController = new AbortController();
332
332
  const task = this.options.taskId ? this.taskResolver(this.options.taskId) : null;
333
- const command = task?.command ?? this.options.command;
334
- const commandArgs = task?.commandArgs ?? this.options.commandArgs;
335
333
  const cwd = resolveEffectiveCwd(this.options.cwd, this.projectDirectory);
336
334
  const chainContext = {};
337
335
  const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
338
- const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, hasChainTasks);
336
+ // Determine the command(s) to run
337
+ const taskCommands = task?.commands?.length
338
+ ? task.commands
339
+ : [{
340
+ command: task?.command ?? this.options.command,
341
+ commandArgs: task?.commandArgs ?? this.options.commandArgs,
342
+ commandRaw: task?.commandRaw ?? this.options.commandRaw,
343
+ }];
344
+ const shouldCaptureStdout = hasChainTasks || taskCommands.length > 1;
345
+ const results = await Promise.allSettled(taskCommands.map((cmd) => executeCommand(cmd.command, cmd.commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, shouldCaptureStdout)));
339
346
  this.runAbortController = null;
340
- if (hasChainTasks && result.stdout) {
347
+ // Find first failure (if any) — stop-immediately policy
348
+ const firstFailure = results.find((r) => r.status === "rejected");
349
+ const firstNonZero = results.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
350
+ const exitCode = firstFailure ? 1 : (firstNonZero?.value.exitCode ?? 0);
351
+ const totalDuration = results.reduce((sum, r) => {
352
+ if (r.status === "fulfilled")
353
+ return sum + r.value.duration;
354
+ return sum;
355
+ }, 0);
356
+ const combinedStdout = results
357
+ .filter((r) => r.status === "fulfilled" && !!r.value.stdout)
358
+ .map((r) => r.value.stdout)
359
+ .join("\n");
360
+ const result = {
361
+ exitCode,
362
+ duration: totalDuration,
363
+ stdout: combinedStdout || undefined,
364
+ };
365
+ if (shouldCaptureStdout && result.stdout) {
341
366
  const parsed = parseStdout(result.stdout);
342
367
  if (parsed !== null) {
343
368
  Object.assign(chainContext, parsed);
@@ -139,7 +139,6 @@ export class HttpApiServer {
139
139
  const segments = path.split("/").filter((s) => s.length > 0);
140
140
  this.routes.push({ method, segments, handler });
141
141
  };
142
- // ── Loops ──────────────────────────────────────────────────────
143
142
  r("GET", "/api/loops", (_req, res) => {
144
143
  sendOk(res, this.manager.list());
145
144
  });
@@ -249,7 +248,6 @@ export class HttpApiServer {
249
248
  const count = this.manager.stopAllLoops();
250
249
  sendOk(res, { count });
251
250
  });
252
- // ── Loop logs ─────────────────────────────────────────────────
253
251
  r("GET", "/api/loops/:id/logs", (_req, res, params) => {
254
252
  const logPath = this.manager.getLogPath(params.id);
255
253
  if (!logPath) {
@@ -351,7 +349,6 @@ export class HttpApiServer {
351
349
  });
352
350
  sendOk(res, parts.join(""));
353
351
  });
354
- // ── Tasks ──────────────────────────────────────────────────────
355
352
  r("GET", "/api/tasks", (_req, res) => {
356
353
  sendOk(res, this.taskManager.list());
357
354
  });
@@ -402,7 +399,6 @@ export class HttpApiServer {
402
399
  }
403
400
  sendOk(res);
404
401
  });
405
- // ── Projects ───────────────────────────────────────────────────
406
402
  r("GET", "/api/projects", (_req, res) => {
407
403
  sendOk(res, this.projectManager.getAll());
408
404
  });
@@ -455,7 +451,6 @@ export class HttpApiServer {
455
451
  }
456
452
  }
457
453
  });
458
- // ── Swagger / OpenAPI ────────────────────────────────────────
459
454
  r("GET", "/api/openapi.json", (_req, res) => {
460
455
  const spec = this.buildOpenApiSpec();
461
456
  const body = JSON.stringify(spec, null, 2);
@@ -481,7 +476,6 @@ export class HttpApiServer {
481
476
  });
482
477
  res.end(html);
483
478
  });
484
- // ── SSE Events ────────────────────────────────────────────────
485
479
  r("GET", "/api/events", (_req, res) => {
486
480
  res.writeHead(200, {
487
481
  "Content-Type": "text/event-stream",
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import { parseDuration } from "../duration.js";
3
3
  import { parseMaxRuns } from "../loop-config.js";
4
4
  import { t } from "../i18n/index.js";
5
- // ── Types ───────────────────────────────────────────────────────────
6
5
  export const createFields = [
7
6
  "interval",
8
7
  "taskMode",
@@ -14,7 +13,6 @@ export const createFields = [
14
13
  "maxRuns",
15
14
  "project",
16
15
  ];
17
- // ── Validation ──────────────────────────────────────────────────────
18
16
  /**
19
17
  * Validates a single loop form field and returns an error string, or
20
18
  * `null` if the value is valid.
@@ -30,7 +28,6 @@ export const createFields = [
30
28
  function validateField(key, values) {
31
29
  const value = values[key] ?? "";
32
30
  switch (key) {
33
- // ── interval ──────────────────────────────────────────────────
34
31
  // Always required; delegates to parseDuration() which throws on
35
32
  // empty, invalid syntax, or non-positive values.
36
33
  case "interval": {
@@ -45,7 +42,6 @@ function validateField(key, values) {
45
42
  return err instanceof Error ? err.message : String(err);
46
43
  }
47
44
  }
48
- // ── command ───────────────────────────────────────────────────
49
45
  // Required only when the user chose inline mode.
50
46
  case "command": {
51
47
  if (values.taskMode === "existing")
@@ -55,7 +51,6 @@ function validateField(key, values) {
55
51
  }
56
52
  return null;
57
53
  }
58
- // ── cwd ───────────────────────────────────────────────────────
59
54
  // Optional; if provided the directory must exist on disk.
60
55
  case "cwd": {
61
56
  if (!value.trim())
@@ -65,7 +60,6 @@ function validateField(key, values) {
65
60
  }
66
61
  return null;
67
62
  }
68
- // ── description ───────────────────────────────────────────────
69
63
  // Always required.
70
64
  case "description": {
71
65
  if (!value.trim()) {
@@ -73,7 +67,6 @@ function validateField(key, values) {
73
67
  }
74
68
  return null;
75
69
  }
76
- // ── maxRuns ───────────────────────────────────────────────────
77
70
  // Optional; if provided it must be a positive integer.
78
71
  case "maxRuns": {
79
72
  if (!value.trim())
@@ -86,7 +79,6 @@ function validateField(key, values) {
86
79
  return err instanceof Error ? err.message : String(err);
87
80
  }
88
81
  }
89
- // ── no validation needed ──────────────────────────────────────
90
82
  case "taskMode":
91
83
  case "runNow":
92
84
  case "project":
@@ -116,7 +108,6 @@ function validateAll(values) {
116
108
  }
117
109
  return errors;
118
110
  }
119
- // ── Hook ────────────────────────────────────────────────────────────
120
111
  /**
121
112
  * Shared form-validation hook for loop creation / edit forms.
122
113
  *
package/dist/i18n/en.json CHANGED
@@ -279,7 +279,7 @@
279
279
  "cmdEditor.lines": "lines",
280
280
  "cmdEditor.inlineHint": "enter: new line . ctrl+v: paste . ctrl+y: copy . tab: next field . ctrl+s: save",
281
281
  "codeEditor.title": "Command Editor",
282
- "codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo",
282
+ "codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo . use && for parallel commands",
283
283
  "codeEditor.buttonCopy": "Copy",
284
284
  "codeEditor.buttonPaste": "Paste",
285
285
  "codeEditor.buttonClear": "Clear",
@@ -290,6 +290,14 @@
290
290
  "codeEditor.fieldHint": "press enter to open editor",
291
291
  "codeEditor.copied": "Copied!",
292
292
  "codeEditor.cleared": "Cleared",
293
+ "codeEditor.clipboardEmpty": "Clipboard empty — right-click to paste",
294
+ "codeEditor.commandsTitle": "Parallel Commands",
295
+ "codeEditor.commandsHint": "tab: next . shift+tab: prev . ctrl+s: save all . esc: cancel",
296
+ "codeEditor.commandLabel": "Command",
297
+ "codeEditor.commandEmpty": "empty = deleted",
298
+ "codeEditor.commandMax": "max 4",
299
+ "wizard.taskCommandsPrompt": "Commands to run (parallel)",
300
+ "wizard.taskCommandsHint": "Multiple commands run at once, merge JSON output",
293
301
  "board.exampleInterval": "30m",
294
302
  "board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
295
303
  "board.exampleDescription": "translate missing strings",
@@ -1,39 +1,109 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { platform } from "node:os";
3
+ /**
4
+ * Write text to the system clipboard. Never throws — returns false if no
5
+ * clipboard tool is available (e.g. a headless SSH box without xclip/xsel).
6
+ *
7
+ * Linux toolchain tried in order: xclip → xsel → wl-copy (Wayland) →
8
+ * tmux load-buffer (when $TMUX is set) → OSC 52 (works over SSH because
9
+ * the local terminal owns the clipboard).
10
+ */
3
11
  export function copyToClipboard(text) {
4
12
  const os = platform();
5
13
  if (os === "win32") {
6
- execFileSync("clip", { input: text });
14
+ try {
15
+ execFileSync("clip", { input: text });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
7
21
  }
8
- else if (os === "darwin") {
9
- execFileSync("pbcopy", { input: text });
22
+ if (os === "darwin") {
23
+ try {
24
+ execFileSync("pbcopy", { input: text });
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
10
30
  }
11
- else {
31
+ // Linux / BSD: try each clipboard tool in order. All calls are guarded —
32
+ // a missing binary must never throw, since callers invoke this from
33
+ // synchronous keypress handlers (an uncaught throw here kills the app).
34
+ const tries = [
35
+ () => execFileSync("xclip", ["-selection", "clipboard"], { input: text }),
36
+ () => execFileSync("xsel", ["--clipboard", "--input"], { input: text }),
37
+ () => execFileSync("wl-copy", [], { input: text }),
38
+ ];
39
+ for (const tryFn of tries) {
12
40
  try {
13
- execFileSync("xclip", ["-selection", "clipboard"], { input: text });
41
+ tryFn();
42
+ return true;
14
43
  }
15
44
  catch {
16
- execFileSync("xsel", ["--clipboard", "--input"], { input: text });
45
+ // try next tool
17
46
  }
18
47
  }
48
+ if (process.env.TMUX) {
49
+ try {
50
+ execFileSync("tmux", ["load-buffer", "-"], { input: text });
51
+ return true;
52
+ }
53
+ catch {
54
+ // fall through to OSC 52
55
+ }
56
+ }
57
+ // OSC 52: ask the connected terminal to copy. Works over SSH because the
58
+ // local terminal owns the clipboard. Most modern terminals support it
59
+ // (iTerm2, WezTerm, Windows Terminal, Kitty, Alacritty).
60
+ try {
61
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
19
67
  }
68
+ /**
69
+ * Read text from the system clipboard. Returns "" if unavailable.
70
+ *
71
+ * Linux toolchain: xclip → xsel → wl-paste → tmux save-buffer -. OSC 52
72
+ * reply is not solicited synchronously (timings are unreliable across
73
+ * terminals); callers fall back to bracketed-paste detection for input.
74
+ */
20
75
  export function readFromClipboard() {
21
76
  const os = platform();
22
77
  try {
23
78
  if (os === "win32") {
24
79
  return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
25
80
  }
26
- else if (os === "darwin") {
81
+ if (os === "darwin") {
27
82
  return execFileSync("pbpaste", { encoding: "utf-8" });
28
83
  }
29
- else {
84
+ // Linux / BSD
85
+ const tries = [
86
+ () => execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" }),
87
+ () => execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" }),
88
+ () => execFileSync("wl-paste", [], { encoding: "utf-8" }),
89
+ ];
90
+ for (const tryFn of tries) {
30
91
  try {
31
- return execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" });
92
+ return tryFn();
32
93
  }
33
94
  catch {
34
- return execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" });
95
+ // try next tool
35
96
  }
36
97
  }
98
+ if (process.env.TMUX) {
99
+ try {
100
+ return execFileSync("tmux", ["save-buffer", "-"], { encoding: "utf-8" });
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ }
106
+ return "";
37
107
  }
38
108
  catch {
39
109
  return "";
package/dist/tui/App.js CHANGED
@@ -42,10 +42,8 @@ export function App(props) {
42
42
  const { exit } = useApp();
43
43
  const { loops, daemonStatus, refresh } = useLoopPolling();
44
44
  const { view, push, pop } = useRouter("board");
45
- // ── Tab and panel state (8.1, 8.2) ──
46
45
  const [activeTab, setActiveTab] = useState("loops");
47
46
  const [focusedPanel, setFocusedPanel] = useState("left");
48
- // ── Filter state ──
49
47
  const [filters, setFilters] = useState(defaultFilters);
50
48
  const [sort, setSort] = useState("description");
51
49
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -66,19 +64,15 @@ export function App(props) {
66
64
  const [currentProjectId, setCurrentProjectId] = useState("all");
67
65
  const [projectSelectedIndex, setProjectSelectedIndex] = useState(0);
68
66
  const [projectFilters, setProjectFilters] = useState(defaultProjectFilters);
69
- // ── Overlay state ──
70
67
  const [commandsBrowserOpen, setCommandsBrowserOpen] = useState(false);
71
68
  const [contextHelpOpen, setContextHelpOpen] = useState(false);
72
69
  const [exportModal, setExportModal] = useState(null);
73
- // ── Confirm state (8.3) ──
74
70
  const [confirmState, setConfirmState] = useState(null);
75
- // ── Search state (command-driven search) ──
76
71
  const [searchState, setSearchState] = useState(null);
77
72
  const [searchValue, setSearchValue] = useState("");
78
73
  const [debugMode, setDebugMode] = useState(false);
79
74
  const [debugEntries, setDebugEntries] = useState([]);
80
75
  const [chordState, setChordState] = useState(null);
81
- // ── Command bar input state (for InputOwner resolution) ──
82
76
  const [commandBarHasText, setCommandBarHasText] = useState(false);
83
77
  const [commandBarDropdownOpen, setCommandBarDropdownOpen] = useState(false);
84
78
  const { toasts, push: pushToast } = useToasts();
@@ -190,7 +184,6 @@ export function App(props) {
190
184
  if (handler)
191
185
  handler();
192
186
  }, [activeTab, selected, selectedTask, selectedProjectEntity, pushToast]);
193
- // ── Command handler — dictionary dispatch, no switch/case ──
194
187
  const commandHandlers = {
195
188
  edit: () => {
196
189
  if (activeTab === "loops" && selected) {
@@ -332,7 +325,6 @@ export function App(props) {
332
325
  pushToast("error", `Unknown command: ${value}`);
333
326
  }
334
327
  }
335
- // ── Confirm handlers (8.3) ──
336
328
  const handleConfirmYes = useCallback(() => {
337
329
  if (confirmState) {
338
330
  confirmState.onConfirm();
@@ -368,11 +360,9 @@ export function App(props) {
368
360
  pop();
369
361
  pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
370
362
  };
371
- // ── Resolve query for LeftPanel based on activeTab + search state ──
372
363
  const leftPanelQuery = searchState?.active
373
364
  ? searchValue
374
365
  : activeTab === "tasks" ? taskQuery : filters.query;
375
- // ── Search handlers ──
376
366
  const handleSearchChange = useCallback((value) => {
377
367
  setSearchValue(value);
378
368
  if (activeTab === "tasks") {
@@ -395,7 +385,6 @@ export function App(props) {
395
385
  }
396
386
  setSearchState(null);
397
387
  }, [activeTab]);
398
- // ── Command context (8.3) ──
399
388
  const commandContext = useMemo(() => ({ activeTab, selectedLoop: selected, selectedTask, selectedProject: selectedProjectEntity }), [activeTab, selected, selectedTask]);
400
389
  // Contextual action for the focused panel: shared by Ctrl+Enter and, for
401
390
  // terminals that collapse Ctrl+Enter to plain Enter, by Enter on an empty
@@ -433,7 +422,6 @@ export function App(props) {
433
422
  const handlerKey = activeTab !== "loops" ? `${activeTab}:` : `loops:${focusedPanel}`;
434
423
  handlers[handlerKey]?.();
435
424
  };
436
- // ── Pop topmost overlay layer (Escape handler helper) ──
437
425
  const popLayer = () => {
438
426
  if (confirmState) {
439
427
  setConfirmState(null);
@@ -472,7 +460,6 @@ export function App(props) {
472
460
  });
473
461
  return true;
474
462
  };
475
- // ── Global useInput (8.2) ──
476
463
  useInput((input, key) => {
477
464
  // Ctrl+C always quits if no modal open
478
465
  if (key.ctrl && input === "c") {
@@ -579,7 +566,6 @@ export function App(props) {
579
566
  // Search mode is handled by CommandInput component, not here (non-Escape only)
580
567
  if (searchState?.active && !key.escape)
581
568
  return;
582
- // ── Escape: pop the topmost overlay layer (2.2) ──
583
569
  if (key.escape) {
584
570
  popLayer();
585
571
  return;
@@ -642,7 +628,6 @@ export function App(props) {
642
628
  // Modals disable panel input behind them; CommandInput stays active for confirm/search
643
629
  const anyModalOpen = !!(logModalRun || commandsBrowserOpen || exportModal);
644
630
  const commandInputDisabled = anyModalOpen;
645
- // ── Resolved input owner (1.2) ──
646
631
  const inputOwner = useMemo(() => resolveInputOwner({
647
632
  modalOpen: !!(logModalRun || commandsBrowserOpen || exportModal || contextHelpOpen || confirmState || searchState?.active),
648
633
  commandBarHasText,
@@ -1,6 +1,5 @@
1
1
  import { COMMAND_TIER_ACTION, COMMAND_TIER_CONFIRM, COMMAND_TIER_GLOBAL, COMMAND_CATEGORY_GLOBAL, COMMAND_CATEGORY_FILTERS, COMMAND_CATEGORY_LOOP, COMMAND_CATEGORY_TASK, COMMAND_CATEGORY_PROJECT, } from '../config/constants.js';
2
2
  import { t } from '../i18n/index.js';
3
- // ── Shared helpers ───────────────────────────────────────────────────
4
3
  function globalCommands() {
5
4
  return [
6
5
  { label: t('cmd.help'), value: 'help', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+p' },
@@ -29,7 +28,6 @@ function projectFilterCommands() {
29
28
  { label: t('cmd.searchProjects'), value: 'search', hint: '', tier: COMMAND_TIER_ACTION, category: COMMAND_CATEGORY_FILTERS, shortcut: 'ctrl+f+s' },
30
29
  ];
31
30
  }
32
- // ── Command ranking (exact > prefix > fuzzy) ─────────────────────────
33
31
  /**
34
32
  * Re-rank autocomplete options: exact match → prefix match → fuzzy (existing order).
35
33
  * Stable within each group. Exported for testing.
@@ -56,7 +54,6 @@ export function rankCommands(input, options) {
56
54
  }
57
55
  return [...exact, ...prefix, ...fuzzy];
58
56
  }
59
- // ── Autocomplete dropdown (context-sensitive, shown while typing) ────
60
57
  export function buildCommands(context) {
61
58
  const commands = [...globalCommands()];
62
59
  if (context.activeTab === 'loops') {
@@ -95,7 +92,6 @@ export function buildCommands(context) {
95
92
  }
96
93
  return commands;
97
94
  }
98
- // ── Ctrl+P commands browser (shows all available commands for this tab) ─
99
95
  export function buildTabCommands(context) {
100
96
  const commands = [...globalCommands()];
101
97
  if (context.activeTab === 'loops') {
@@ -7,13 +7,22 @@ import { useUndoRedo } from "../../shared/useUndoRedo.js";
7
7
  import { highlightSegments } from "../utils/syntax.js";
8
8
  import { joinCommandLines } from "../../loop-config.js";
9
9
  import { copyToClipboard, readFromClipboard } from "../../shared/clipboard.js";
10
- import { sanitizePaste } from "../utils/paste.js";
11
- import { CODE_EDITOR_MODAL_HEIGHT, CODE_EDITOR_UNDO_LIMIT, CODE_EDITOR_SYNTAX_COLORS, } from "../../config/constants.js";
12
- // Lines available for the editor content area:
13
- // total height - title row - preview row - buttons row - 2 padding = -5
14
- const EDITOR_VISIBLE_LINES = CODE_EDITOR_MODAL_HEIGHT - 5;
10
+ import { sanitizeMultilinePaste } from "../utils/paste.js";
11
+ import { CODE_EDITOR_MODAL_HEIGHT, CODE_EDITOR_MODAL_WIDTH, CODE_EDITOR_UNDO_LIMIT, CODE_EDITOR_SYNTAX_COLORS, } from "../../config/constants.js";
12
+ // Cap modal to terminal size: never exceed (terminal - 2) so the border
13
+ // and a 1-row margin are always visible. Ideal max is the constant.
14
+ function useModalDimensions() {
15
+ const termRows = process.stdout.rows || 24;
16
+ const termCols = process.stdout.columns || 80;
17
+ const modalHeight = Math.min(CODE_EDITOR_MODAL_HEIGHT, termRows - 2);
18
+ const modalWidth = Math.min(CODE_EDITOR_MODAL_WIDTH, termCols - 2);
19
+ // total height - title - preview - buttons - hint - 2 padding = -6
20
+ const editorVisibleLines = Math.max(3, modalHeight - 6);
21
+ return { modalWidth, modalHeight, editorVisibleLines };
22
+ }
15
23
  export function CodeEditorModal(props) {
16
24
  const { initialValue, onSave, onCancel } = props;
25
+ const { modalWidth, modalHeight, editorVisibleLines: EDITOR_VISIBLE_LINES } = useModalDimensions();
17
26
  const { value, setValue, undo, redo } = useUndoRedo(initialValue, CODE_EDITOR_UNDO_LIMIT);
18
27
  const [cursorRow, setCursorRow] = useState(() => {
19
28
  const lines = initialValue.split("\n");
@@ -49,18 +58,32 @@ export function CodeEditorModal(props) {
49
58
  }, [setValue]);
50
59
  // ---- Keyboard handling ----
51
60
  useInput((input, key) => {
61
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
62
+ // Must be detected BEFORE the escape check — the leading ESC trips
63
+ // key.escape and would close the modal before paste is handled.
64
+ if (input.includes("\x1b[200~")) {
65
+ const pasted = sanitizeMultilinePaste(input);
66
+ if (pasted) {
67
+ const next = [...lines];
68
+ const line = next[cursorRow] ?? "";
69
+ next[cursorRow] =
70
+ line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
71
+ applyMutation(next, cursorRow, cursorCol + pasted.length);
72
+ }
73
+ return;
74
+ }
52
75
  // Esc → cancel
53
76
  if (key.escape) {
54
77
  onCancel();
55
78
  return;
56
79
  }
57
- // Ctrl+S → save
58
- if (key.ctrl && input === "s") {
80
+ // Ctrl+S → save (also accept raw \x13 fallback)
81
+ if ((key.ctrl && input === "s") || input === "\x13") {
59
82
  onSave(value);
60
83
  return;
61
84
  }
62
- // Ctrl+Z → undo
63
- if (key.ctrl && !key.shift && input === "z") {
85
+ // Ctrl+Z → undo (also accept raw \x1a fallback)
86
+ if ((key.ctrl && !key.shift && input === "z") || input === "\x1a") {
64
87
  undo();
65
88
  return;
66
89
  }
@@ -69,8 +92,8 @@ export function CodeEditorModal(props) {
69
92
  redo();
70
93
  return;
71
94
  }
72
- // Ctrl+X → cut (copy all to clipboard, then clear)
73
- if (key.ctrl && input === "x") {
95
+ // Ctrl+X → cut (copy all to clipboard, then clear; also accept \x18)
96
+ if ((key.ctrl && input === "x") || input === "\x18") {
74
97
  copyToClipboard(value);
75
98
  setValue("");
76
99
  setCursorRow(0);
@@ -78,11 +101,12 @@ export function CodeEditorModal(props) {
78
101
  setFlashMsg(t("codeEditor.copied"));
79
102
  return;
80
103
  }
81
- // Ctrl+V → paste from clipboard
82
- if (key.ctrl && input === "v") {
104
+ // Ctrl+V → paste from clipboard (also accept raw \x16 fallback for
105
+ // terminals that don't set key.ctrl on the SYN control byte)
106
+ if ((key.ctrl && input === "v") || input === "\x16") {
83
107
  const clip = readFromClipboard();
84
108
  if (clip) {
85
- const pasted = sanitizePaste(clip);
109
+ const pasted = sanitizeMultilinePaste(clip);
86
110
  if (pasted) {
87
111
  const next = [...lines];
88
112
  const line = next[cursorRow] ?? "";
@@ -91,10 +115,15 @@ export function CodeEditorModal(props) {
91
115
  applyMutation(next, cursorRow, cursorCol + pasted.length);
92
116
  }
93
117
  }
118
+ else {
119
+ // No clipboard tool available (common over SSH). Bracketed paste
120
+ // (right-click in the terminal) still works — point the user at it.
121
+ setFlashMsg(t("codeEditor.clipboardEmpty"));
122
+ }
94
123
  return;
95
124
  }
96
125
  // Ctrl+L → clear all
97
- if (key.ctrl && input === "l") {
126
+ if ((key.ctrl && input === "l") || input === "\x0c") {
98
127
  setValue("");
99
128
  setCursorRow(0);
100
129
  setCursorCol(0);
@@ -165,24 +194,10 @@ export function CodeEditorModal(props) {
165
194
  }
166
195
  return;
167
196
  }
168
- // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
169
- if (input.includes("\x1b[200~")) {
170
- const pasted = sanitizePaste(input);
171
- if (pasted) {
172
- const next = [...lines];
173
- const line = next[cursorRow] ?? "";
174
- next[cursorRow] =
175
- line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
176
- applyMutation(next, cursorRow, cursorCol + pasted.length);
177
- }
178
- return;
179
- }
180
- // Multi-char containing CR/LF with no bracketed markers — ignore
181
- if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
182
- return;
183
- // Multi-char printable input = unbracketed single-line paste
197
+ // Multi-char printable input = unbracketed paste (e.g. right-click
198
+ // in terminals that don't support DECSET 2004). Preserve newlines.
184
199
  if (input.length > 1 && !key.meta) {
185
- const pasted = sanitizePaste(input);
200
+ const pasted = sanitizeMultilinePaste(input);
186
201
  if (pasted) {
187
202
  const next = [...lines];
188
203
  const line = next[cursorRow] ?? "";
@@ -203,12 +218,12 @@ export function CodeEditorModal(props) {
203
218
  }, { isActive: true });
204
219
  // ---- Live preview footer ----
205
220
  const joined = joinCommandLines(value);
206
- const innerWidth = 58;
221
+ const innerWidth = modalWidth - 2;
207
222
  const truncatedPreview = joined.length > innerWidth
208
223
  ? joined.slice(0, innerWidth - 1) + t("codeEditor.previewTruncated")
209
224
  : joined;
210
225
  const accent = theme.accent.brand;
211
- return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: 60, height: CODE_EDITOR_MODAL_HEIGHT, flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: accent, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: accent, bold: true, children: t("codeEditor.title") }), _jsxs(Text, { color: theme.text.muted, children: [lineCount, " ", t("cmdEditor.lines")] })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: visibleLines.map((line, visIdx) => {
226
+ return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: modalWidth, height: modalHeight, flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: accent, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: accent, bold: true, children: t("codeEditor.title") }), _jsxs(Text, { color: theme.text.muted, children: [lineCount, " ", t("cmdEditor.lines")] })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: visibleLines.map((line, visIdx) => {
212
227
  const rowIdx = scrollStart + visIdx;
213
228
  const isCursor = rowIdx === cursorRow;
214
229
  const lineNum = String(rowIdx + 1).padStart(lineNumWidth, " ");
@@ -8,7 +8,6 @@ import { buildCommands, rankCommands } from "../commands.js";
8
8
  import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, } from "../../config/constants.js";
9
9
  import { sanitizePaste } from "../utils/paste.js";
10
10
  export { sanitizePaste } from "../utils/paste.js";
11
- // ── Rendered input with cursor ───────────────────────────────────────
12
11
  function renderInputLine(value, cursorOffset) {
13
12
  if (value.length === 0) {
14
13
  return "\x1b[7m \x1b[27m";
@@ -54,7 +53,6 @@ function renderHighlightedLabel(label, matchRanges, isFocused) {
54
53
  }
55
54
  return result;
56
55
  }
57
- // ── Dropdown ─────────────────────────────────────────────────────────
58
56
  function CommandDropdown({ state, rankedFiltered, }) {
59
57
  if (!state.isOpen || state.isLoading || state.error)
60
58
  return null;
@@ -75,15 +73,12 @@ function CommandDropdown({ state, rankedFiltered, }) {
75
73
  return (_jsxs(Box, { backgroundColor: isFocused ? theme.bg.active : undefined, children: [_jsx(Text, { color: isFocused ? theme.text.inverse : theme.text.muted, children: isFocused ? "\u276f " : " " }), _jsx(Text, { color: isFocused ? theme.text.inverse : undefined, children: label })] }, match.option.value));
76
74
  }), belowCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2193 ${belowCount} more` }))] }));
77
75
  }
78
- // ── Confirm inline options ────────────────────────────────────────────
79
- // ── Hint bar ──────────────────────────────────────────────────────────
80
76
  function HintBar({ leftHint, rightHint, }) {
81
77
  return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Box, { children: leftHint }), _jsx(Box, { children: rightHint })] }));
82
78
  }
83
79
  function KeyHint({ keyLabel, action }) {
84
80
  return (_jsxs(Box, { marginRight: 2, children: [_jsx(Text, { bold: true, color: theme.text.primary, children: keyLabel }), _jsx(Text, { color: theme.text.muted, children: " " + action })] }));
85
81
  }
86
- // ── Command mode ─────────────────────────────────────────────────────
87
82
  function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navOwner, onInputStateChange, }) {
88
83
  const commands = useMemo(() => buildCommands(context), [context]);
89
84
  const options = useMemo(() => commands.map((cmd) => ({ label: cmd.label, value: cmd.value })), [commands]);
@@ -210,7 +205,6 @@ function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navO
210
205
  const inputContent = isEmpty ? cursor : renderInputLine(state.inputValue, state.cursorOffset);
211
206
  return (_jsxs(_Fragment, { children: [_jsx(CommandDropdown, { state: state, rankedFiltered: rankedFiltered }), _jsxs(Box, { children: [_jsx(Text, { color: theme.accent.brand, children: "│ " }), isEmpty ? (_jsx(Text, { color: theme.text.muted, children: t("cmdInput.placeholder") })) : (_jsx(Text, { children: inputContent }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsxs(Box, { children: [_jsx(KeyHint, { keyLabel: "enter", action: "edit/logs" }), _jsx(KeyHint, { keyLabel: "ctrl+u", action: "clear" }), _jsx(KeyHint, { keyLabel: "c", action: "copy" }), _jsx(KeyHint, { keyLabel: "tab", action: "panels" }), _jsx(KeyHint, { keyLabel: "ctrl+\u2190\u2192", action: "tabs" }), _jsx(KeyHint, { keyLabel: "ctrl+p", action: "commands" })] }) })] }));
212
207
  }
213
- // ── Confirm mode ─────────────────────────────────────────────────────
214
208
  function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, }) {
215
209
  const [value, setValue] = useState("");
216
210
  useInput((input, key) => {
@@ -246,7 +240,6 @@ function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, })
246
240
  const cursor = "\x1b[7m \x1b[27m";
247
241
  return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.muted, children: confirmState.prompt + " " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { children: cursor }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
248
242
  }
249
- // ── Search mode ──────────────────────────────────────────────────────
250
243
  function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, disabled, }) {
251
244
  useInput((input, key) => {
252
245
  if (key.ctrl)
@@ -274,7 +267,6 @@ function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, dis
274
267
  const cursor = "\x1b[7m \x1b[27m";
275
268
  return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent.project, children: "│ " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { color: theme.text.muted, children: placeholder }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "apply" }) })] }));
276
269
  }
277
- // ── Main component ───────────────────────────────────────────────────
278
270
  export function CommandInput(props) {
279
271
  const { context, onCommand, confirmState, searchState, searchValue, onSearchChange, onSearchSubmit, onSearchCancel, onConfirmYes, onConfirmCancel, onCopy, onPanelAction, navOwner, onInputStateChange, } = props;
280
272
  return (_jsx(Box, { flexDirection: "column", height: COMMAND_INPUT_HEIGHT, borderStyle: "single", borderColor: theme.border.dim, paddingY: 1, children: searchState?.active ? (_jsx(SearchMode, { value: searchValue, onSearchChange: onSearchChange, onSearchSubmit: onSearchSubmit, onSearchCancel: onSearchCancel, disabled: props.disabled })) : confirmState === null ? (_jsx(CommandMode, { context: context, onCommand: onCommand, onCopy: onCopy, onPanelAction: onPanelAction, disabled: props.disabled, navOwner: navOwner, onInputStateChange: onInputStateChange })) : (_jsx(ConfirmMode, { confirmState: confirmState, onConfirmYes: onConfirmYes, onConfirmCancel: onConfirmCancel, disabled: props.disabled })) }));
@@ -9,7 +9,6 @@ import { CodeEditorPreview } from "./CodeEditorPreview.js";
9
9
  import { CodeEditorModal } from "./CodeEditorModal.js";
10
10
  import { parseDuration } from "../../duration.js";
11
11
  import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
12
- // ── Component ───────────────────────────────────────────────────────
13
12
  export function CreateView(props) {
14
13
  const { mode, editId, initial, selectedTaskId, selectedTaskName, tasks, currentProjectId, onCancel, onDone, onChooseTask, } = props;
15
14
  const [taskPickerOpen, setTaskPickerOpen] = useState(false);
@@ -14,6 +14,12 @@ export function ExportModal(props) {
14
14
  const maxScroll = Math.max(0, displayLines.length - VISIBLE_LINES);
15
15
  const [scrollOffset, setScrollOffset] = useState(0);
16
16
  useInput((input, key) => {
17
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
18
+ // before the escape check — the leading ESC trips key.escape and would
19
+ // close the modal on a right-click paste.
20
+ if (input.includes("\x1b[200~")) {
21
+ return;
22
+ }
17
23
  if (key.escape) {
18
24
  props.onClose();
19
25
  return;
@@ -54,6 +54,13 @@ export function LogModal(props) {
54
54
  const endIdx = startIdx + MAX_VISIBLE_LINES;
55
55
  const visible = filtered.slice(startIdx, endIdx);
56
56
  useInput((input, key) => {
57
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
58
+ // before the escape check — the leading ESC trips key.escape and would
59
+ // close the modal before the paste is acknowledged. LogModal doesn't
60
+ // insert pastes, so just swallow the sequence.
61
+ if (input.includes("\x1b[200~")) {
62
+ return;
63
+ }
57
64
  if (searchMode) {
58
65
  if (key.escape || key.return) {
59
66
  setSearchMode(false);
@@ -7,8 +7,7 @@ import { CodeEditorModal } from "./CodeEditorModal.js";
7
7
  import { createTask, updateTask, listTasks } from "../daemon.js";
8
8
  import crypto from "node:crypto";
9
9
  import { t } from "../../i18n/index.js";
10
- import { joinCommandLines } from "../../loop-config.js";
11
- // ── Utility ─────────────────────────────────────────────────────────
10
+ import { joinCommandLines, parseCommandLine } from "../../loop-config.js";
12
11
  function parseArgs(cmd) {
13
12
  const tokens = [];
14
13
  const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
@@ -21,11 +20,14 @@ function parseArgs(cmd) {
21
20
  function joinCommand(task) {
22
21
  return [task.command, ...task.commandArgs].join(" ");
23
22
  }
24
- // ── Component ───────────────────────────────────────────────────────
25
23
  export function TaskForm(props) {
26
24
  const { mode, editTask, onCancel, onDone } = props;
27
25
  const [tasks, setTasks] = useState([]);
28
- const [commandValue, setCommandValue] = useState(editTask ? (editTask.commandRaw ?? joinCommand(editTask)) : "");
26
+ const [commandValue, setCommandValue] = useState(editTask
27
+ ? (editTask.commands?.length
28
+ ? editTask.commands.map(c => c.commandRaw ?? [c.command, ...c.commandArgs].join(" ")).join("\n&&\n")
29
+ : (editTask.commandRaw ?? joinCommand(editTask)))
30
+ : "");
29
31
  useEffect(() => {
30
32
  listTasks().then(setTasks).catch(() => setTasks([]));
31
33
  }, []);
@@ -100,14 +102,44 @@ export function TaskForm(props) {
100
102
  return;
101
103
  const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
102
104
  const onFailureTaskId = resolveChainId(values.onFailure ?? "");
103
- const payload = {
104
- name,
105
- command: rawCommand.split(" ")[0] ?? "",
106
- commandArgs: parseArgs(rawCommand),
107
- commandRaw: commandValue,
108
- onSuccessTaskId,
109
- onFailureTaskId,
110
- };
105
+ // Split by && to build parallel commands. If only one command, keep
106
+ // backward compat (command + commandArgs, no commands[]).
107
+ const commandSegments = commandValue
108
+ .split(/\n&&\n|\n&&|&&\n|&&/)
109
+ .map(s => s.trim())
110
+ .filter(s => s.length > 0);
111
+ let payload;
112
+ if (commandSegments.length > 1) {
113
+ const commands = commandSegments.slice(0, 4).map(seg => {
114
+ const joined = joinCommandLines(seg);
115
+ const tokens = parseCommandLine(joined);
116
+ return {
117
+ command: tokens[0] ?? "",
118
+ commandArgs: tokens.slice(1),
119
+ commandRaw: seg,
120
+ };
121
+ });
122
+ // First command also populates command/commandArgs for backward compat
123
+ payload = {
124
+ name,
125
+ command: commands[0].command,
126
+ commandArgs: commands[0].commandArgs,
127
+ commandRaw: commands[0].commandRaw,
128
+ commands,
129
+ onSuccessTaskId,
130
+ onFailureTaskId,
131
+ };
132
+ }
133
+ else {
134
+ payload = {
135
+ name,
136
+ command: rawCommand.split(" ")[0] ?? "",
137
+ commandArgs: parseArgs(rawCommand),
138
+ commandRaw: commandValue,
139
+ onSuccessTaskId,
140
+ onFailureTaskId,
141
+ };
142
+ }
111
143
  if (mode === "edit" && editTask) {
112
144
  updateTask(editTask.id, payload)
113
145
  .then(() => onDone(true, editTask.id))
@@ -123,7 +123,7 @@ export async function exportConfig() {
123
123
  const exportData = {
124
124
  version: 2,
125
125
  exportedAt: new Date().toISOString(),
126
- loops,
126
+ loops: loops.map(({ runHistory: _ignored, ...rest }) => rest),
127
127
  tasks,
128
128
  projects,
129
129
  };
@@ -1,10 +1,16 @@
1
1
  import { PASTE_MAX_CHARS } from "../../config/constants.js";
2
- // Turn a raw paste (possibly bracketed, multi-line, with stray control chars)
3
- // into a single-line insertable string. Exported for tests.
4
2
  export function sanitizePaste(raw) {
5
3
  return raw
6
- .replace(/\x1b\[20[01]~/g, "") // strip bracketed-paste markers ESC[200~/ESC[201~
7
- .replace(/[\r\n]+/g, " ") // command bar is single-line: newlines -> space
8
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") // drop remaining control chars
4
+ .replace(/\x1b\[20[01]~/g, "")
5
+ .replace(/[\r\n]+/g, " ")
6
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
7
+ .slice(0, PASTE_MAX_CHARS);
8
+ }
9
+ export function sanitizeMultilinePaste(raw) {
10
+ return raw
11
+ .replace(/\x1b\[20[01]~/g, "")
12
+ .replace(/\r\n/g, "\n")
13
+ .replace(/\r/g, "\n")
14
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
9
15
  .slice(0, PASTE_MAX_CHARS);
10
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.15",
3
+ "version": "2.1.0",
4
4
  "description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
5
5
  "type": "module",
6
6
  "bin": {