loop-task 2.1.0 → 2.1.2

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.
@@ -74,6 +74,7 @@ export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
74
74
  export const CODE_EDITOR_MAX_VISIBLE = 2;
75
75
  export const CODE_EDITOR_MODAL_HEIGHT = 40;
76
76
  export const CODE_EDITOR_MODAL_WIDTH = 120;
77
+ export const CODE_EDITOR_WRAP_LENGTH = 40;
77
78
  export const CODE_EDITOR_UNDO_LIMIT = 50;
78
79
  export const CODE_EDITOR_SYNTAX_COLORS = {
79
80
  flag: "#38bdf8",
@@ -333,41 +333,48 @@ export class LoopController extends EventEmitter {
333
333
  const cwd = resolveEffectiveCwd(this.options.cwd, this.projectDirectory);
334
334
  const chainContext = {};
335
335
  const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
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)));
336
+ const singleCommandFallback = {
337
+ command: task?.command ?? this.options.command,
338
+ commandArgs: task?.commandArgs ?? this.options.commandArgs,
339
+ commandRaw: task?.commandRaw ?? this.options.commandRaw,
340
+ };
341
+ const taskSteps = task?.steps?.length
342
+ ? task.steps
343
+ : [{ commands: [singleCommandFallback] }];
344
+ const shouldCaptureStdout = hasChainTasks || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
345
+ let exitCode = 0;
346
+ let totalDuration = 0;
347
+ let combinedStdout = "";
348
+ for (const step of taskSteps) {
349
+ const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, shouldCaptureStdout)));
350
+ let stepStdout = "";
351
+ for (const r of stepResults) {
352
+ if (r.status === "fulfilled") {
353
+ totalDuration += r.value.duration;
354
+ if (r.value.stdout)
355
+ stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
356
+ }
357
+ }
358
+ combinedStdout += (combinedStdout && stepStdout ? "\n" : "") + stepStdout;
359
+ if (shouldCaptureStdout && stepStdout) {
360
+ const parsed = parseStdout(stepStdout);
361
+ if (parsed !== null) {
362
+ Object.assign(chainContext, parsed);
363
+ }
364
+ }
365
+ const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
366
+ if (stepFailure) {
367
+ const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
368
+ exitCode = failed ? failed.value.exitCode : 1;
369
+ break;
370
+ }
371
+ }
346
372
  this.runAbortController = null;
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
373
  const result = {
361
374
  exitCode,
362
375
  duration: totalDuration,
363
376
  stdout: combinedStdout || undefined,
364
377
  };
365
- if (shouldCaptureStdout && result.stdout) {
366
- const parsed = parseStdout(result.stdout);
367
- if (parsed !== null) {
368
- Object.assign(chainContext, parsed);
369
- }
370
- }
371
378
  this.lastExitCode = result.exitCode;
372
379
  this.lastDuration = result.duration;
373
380
  const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
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 . use && for parallel commands",
282
+ "codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo . && sequential . || parallel",
283
283
  "codeEditor.buttonCopy": "Copy",
284
284
  "codeEditor.buttonPaste": "Paste",
285
285
  "codeEditor.buttonClear": "Clear",
@@ -7,7 +7,7 @@ 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 { sanitizeMultilinePaste } from "../utils/paste.js";
10
+ import { sanitizeMultilinePaste, wrapCommand } from "../utils/paste.js";
11
11
  import { CODE_EDITOR_MODAL_HEIGHT, CODE_EDITOR_MODAL_WIDTH, CODE_EDITOR_UNDO_LIMIT, CODE_EDITOR_SYNTAX_COLORS, } from "../../config/constants.js";
12
12
  // Cap modal to terminal size: never exceed (terminal - 2) so the border
13
13
  // and a 1-row margin are always visible. Ideal max is the constant.
@@ -62,7 +62,7 @@ export function CodeEditorModal(props) {
62
62
  // Must be detected BEFORE the escape check — the leading ESC trips
63
63
  // key.escape and would close the modal before paste is handled.
64
64
  if (input.includes("\x1b[200~")) {
65
- const pasted = sanitizeMultilinePaste(input);
65
+ const pasted = wrapCommand(sanitizeMultilinePaste(input));
66
66
  if (pasted) {
67
67
  const next = [...lines];
68
68
  const line = next[cursorRow] ?? "";
@@ -197,7 +197,7 @@ export function CodeEditorModal(props) {
197
197
  // Multi-char printable input = unbracketed paste (e.g. right-click
198
198
  // in terminals that don't support DECSET 2004). Preserve newlines.
199
199
  if (input.length > 1 && !key.meta) {
200
- const pasted = sanitizeMultilinePaste(input);
200
+ const pasted = wrapCommand(sanitizeMultilinePaste(input));
201
201
  if (pasted) {
202
202
  const next = [...lines];
203
203
  const line = next[cursorRow] ?? "";
@@ -24,8 +24,8 @@ export function TaskForm(props) {
24
24
  const { mode, editTask, onCancel, onDone } = props;
25
25
  const [tasks, setTasks] = useState([]);
26
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")
27
+ ? (editTask.steps?.length
28
+ ? editTask.steps.map(step => step.commands.map(c => c.commandRaw ?? [c.command, ...c.commandArgs].join(" ")).join("\n||\n")).join("\n&&\n")
29
29
  : (editTask.commandRaw ?? joinCommand(editTask)))
30
30
  : "");
31
31
  useEffect(() => {
@@ -102,30 +102,36 @@ export function TaskForm(props) {
102
102
  return;
103
103
  const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
104
104
  const onFailureTaskId = resolveChainId(values.onFailure ?? "");
105
- // Split by && to build parallel commands. If only one command, keep
106
- // backward compat (command + commandArgs, no commands[]).
107
105
  const commandSegments = commandValue
108
106
  .split(/\n&&\n|\n&&|&&\n|&&/)
109
107
  .map(s => s.trim())
110
108
  .filter(s => s.length > 0);
111
109
  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
- };
110
+ if (commandSegments.length > 1 || commandValue.includes("||")) {
111
+ const steps = commandSegments.slice(0, 8).map(seg => {
112
+ const parallelCmds = seg
113
+ .split(/\n\|\|\n|\n\|\||\|\|\n|\|\|/)
114
+ .map(s => s.trim())
115
+ .filter(s => s.length > 0)
116
+ .slice(0, 4)
117
+ .map(cmdSeg => {
118
+ const joined = joinCommandLines(cmdSeg);
119
+ const tokens = parseCommandLine(joined);
120
+ return {
121
+ command: tokens[0] ?? "",
122
+ commandArgs: tokens.slice(1),
123
+ commandRaw: cmdSeg,
124
+ };
125
+ });
126
+ return { commands: parallelCmds };
121
127
  });
122
- // First command also populates command/commandArgs for backward compat
128
+ const firstCmd = steps[0].commands[0];
123
129
  payload = {
124
130
  name,
125
- command: commands[0].command,
126
- commandArgs: commands[0].commandArgs,
127
- commandRaw: commands[0].commandRaw,
128
- commands,
131
+ command: firstCmd.command,
132
+ commandArgs: firstCmd.commandArgs,
133
+ commandRaw: firstCmd.commandRaw,
134
+ steps,
129
135
  onSuccessTaskId,
130
136
  onFailureTaskId,
131
137
  };
@@ -1,4 +1,5 @@
1
1
  import { PASTE_MAX_CHARS } from "../../config/constants.js";
2
+ import { CODE_EDITOR_WRAP_LENGTH } from "../../config/constants.js";
2
3
  export function sanitizePaste(raw) {
3
4
  return raw
4
5
  .replace(/\x1b\[20[01]~/g, "")
@@ -14,3 +15,23 @@ export function sanitizeMultilinePaste(raw) {
14
15
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
15
16
  .slice(0, PASTE_MAX_CHARS);
16
17
  }
18
+ export function wrapCommand(text, maxLen = CODE_EDITOR_WRAP_LENGTH) {
19
+ const words = text.split(" ");
20
+ const lines = [];
21
+ let current = "";
22
+ for (const word of words) {
23
+ if (current.length === 0) {
24
+ current = word;
25
+ }
26
+ else if (current.length + 1 + word.length <= maxLen) {
27
+ current += " " + word;
28
+ }
29
+ else {
30
+ lines.push(current);
31
+ current = word;
32
+ }
33
+ }
34
+ if (current)
35
+ lines.push(current);
36
+ return lines.join("\n");
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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": {