loop-task 2.0.16 → 2.1.1

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.
@@ -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,19 +330,49 @@ 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
+ 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
+ for (const r of stepResults) {
351
+ if (r.status === "fulfilled") {
352
+ totalDuration += r.value.duration;
353
+ if (r.value.stdout)
354
+ combinedStdout += (combinedStdout ? "\n" : "") + r.value.stdout;
355
+ }
356
+ }
357
+ const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
358
+ if (stepFailure) {
359
+ const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
360
+ exitCode = failed ? failed.value.exitCode : 1;
361
+ break;
362
+ }
363
+ }
339
364
  this.runAbortController = null;
340
- if (hasChainTasks && result.stdout) {
341
- const parsed = parseStdout(result.stdout);
365
+ if (shouldCaptureStdout && combinedStdout) {
366
+ const parsed = parseStdout(combinedStdout);
342
367
  if (parsed !== null) {
343
368
  Object.assign(chainContext, parsed);
344
369
  }
345
370
  }
371
+ const result = {
372
+ exitCode,
373
+ duration: totalDuration,
374
+ stdout: combinedStdout || undefined,
375
+ };
346
376
  this.lastExitCode = result.exitCode;
347
377
  this.lastDuration = result.duration;
348
378
  const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
@@ -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 . && sequential . || parallel",
283
283
  "codeEditor.buttonCopy": "Copy",
284
284
  "codeEditor.buttonPaste": "Paste",
285
285
  "codeEditor.buttonClear": "Clear",
@@ -291,6 +291,13 @@
291
291
  "codeEditor.copied": "Copied!",
292
292
  "codeEditor.cleared": "Cleared",
293
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",
294
301
  "board.exampleInterval": "30m",
295
302
  "board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
296
303
  "board.exampleDescription": "translate missing strings",
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");
@@ -53,7 +62,7 @@ export function CodeEditorModal(props) {
53
62
  // Must be detected BEFORE the escape check — the leading ESC trips
54
63
  // key.escape and would close the modal before paste is handled.
55
64
  if (input.includes("\x1b[200~")) {
56
- const pasted = sanitizePaste(input);
65
+ const pasted = sanitizeMultilinePaste(input);
57
66
  if (pasted) {
58
67
  const next = [...lines];
59
68
  const line = next[cursorRow] ?? "";
@@ -97,7 +106,7 @@ export function CodeEditorModal(props) {
97
106
  if ((key.ctrl && input === "v") || input === "\x16") {
98
107
  const clip = readFromClipboard();
99
108
  if (clip) {
100
- const pasted = sanitizePaste(clip);
109
+ const pasted = sanitizeMultilinePaste(clip);
101
110
  if (pasted) {
102
111
  const next = [...lines];
103
112
  const line = next[cursorRow] ?? "";
@@ -185,12 +194,10 @@ export function CodeEditorModal(props) {
185
194
  }
186
195
  return;
187
196
  }
188
- // Multi-char containing CR/LF with no bracketed markers — ignore
189
- if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
190
- return;
191
- // 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.
192
199
  if (input.length > 1 && !key.meta) {
193
- const pasted = sanitizePaste(input);
200
+ const pasted = sanitizeMultilinePaste(input);
194
201
  if (pasted) {
195
202
  const next = [...lines];
196
203
  const line = next[cursorRow] ?? "";
@@ -211,12 +218,12 @@ export function CodeEditorModal(props) {
211
218
  }, { isActive: true });
212
219
  // ---- Live preview footer ----
213
220
  const joined = joinCommandLines(value);
214
- const innerWidth = 58;
221
+ const innerWidth = modalWidth - 2;
215
222
  const truncatedPreview = joined.length > innerWidth
216
223
  ? joined.slice(0, innerWidth - 1) + t("codeEditor.previewTruncated")
217
224
  : joined;
218
225
  const accent = theme.accent.brand;
219
- 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) => {
220
227
  const rowIdx = scrollStart + visIdx;
221
228
  const isCursor = rowIdx === cursorRow;
222
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);
@@ -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.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
+ : (editTask.commandRaw ?? joinCommand(editTask)))
30
+ : "");
29
31
  useEffect(() => {
30
32
  listTasks().then(setTasks).catch(() => setTasks([]));
31
33
  }, []);
@@ -100,14 +102,50 @@ 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
+ const commandSegments = commandValue
106
+ .split(/\n&&\n|\n&&|&&\n|&&/)
107
+ .map(s => s.trim())
108
+ .filter(s => s.length > 0);
109
+ let payload;
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 };
127
+ });
128
+ const firstCmd = steps[0].commands[0];
129
+ payload = {
130
+ name,
131
+ command: firstCmd.command,
132
+ commandArgs: firstCmd.commandArgs,
133
+ commandRaw: firstCmd.commandRaw,
134
+ steps,
135
+ onSuccessTaskId,
136
+ onFailureTaskId,
137
+ };
138
+ }
139
+ else {
140
+ payload = {
141
+ name,
142
+ command: rawCommand.split(" ")[0] ?? "",
143
+ commandArgs: parseArgs(rawCommand),
144
+ commandRaw: commandValue,
145
+ onSuccessTaskId,
146
+ onFailureTaskId,
147
+ };
148
+ }
111
149
  if (mode === "edit" && editTask) {
112
150
  updateTask(editTask.id, payload)
113
151
  .then(() => onDone(true, editTask.id))
@@ -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.16",
3
+ "version": "2.1.1",
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": {