loop-task 2.0.4 → 2.0.7

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.
@@ -3,6 +3,12 @@ import { execa } from "execa";
3
3
  import { formatDuration } from "../duration.js";
4
4
  import { t } from "../i18n/index.js";
5
5
  import { MAX_CONTEXT_STDOUT_BYTES } from "../config/constants.js";
6
+ function quoteArg(arg) {
7
+ return /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
8
+ }
9
+ function formatCommandLine(command, commandArgs) {
10
+ return [command, ...commandArgs.map((a) => quoteArg(a.replace(/\n/g, " ").replace(/\r/g, " ")))].join(" ").trim();
11
+ }
6
12
  export function extractExitCode(error) {
7
13
  return error && typeof error === "object" && "exitCode" in error
8
14
  ? error.exitCode
@@ -12,6 +18,7 @@ export async function executeCommand(command, commandArgs, cwd, logStream, signa
12
18
  const startedAt = new Date();
13
19
  const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
14
20
  logStream.write(header);
21
+ logStream.write(t("loop.commandLine", { command: formatCommandLine(command, commandArgs) }));
15
22
  if (cwd && !fs.existsSync(cwd)) {
16
23
  const endedAt = new Date();
17
24
  logStream.write(t("loop.cwdMissingLog", { cwd }));
@@ -363,12 +363,15 @@ export class LoopController extends EventEmitter {
363
363
  if (mainRecord)
364
364
  mainRecord.chainGroupId = chainGroupId;
365
365
  let currentTargetId = chainTargetId;
366
+ let prevBranch = result.exitCode === 0 ? "onSuccess" : "onFailure";
367
+ let prevExit = result.exitCode;
366
368
  while (currentTargetId) {
367
369
  const chainTask = this.taskResolver(currentTargetId);
368
370
  if (!chainTask)
369
371
  break;
370
372
  if (this.logStream) {
371
- this.logStream.write(t("loop.chainHeader", { name: chainTask.name }));
373
+ this.logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
374
+ this.logStream.write(t("loop.commandLine", { command: [chainTask.command, ...chainTask.commandArgs.map((a) => /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).map((a) => a.replace(/\n/g, " ").replace(/\r/g, " "))].join(" ").trim() }));
372
375
  }
373
376
  const chainStartedAt = new Date().toISOString();
374
377
  const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
@@ -403,6 +406,8 @@ export class LoopController extends EventEmitter {
403
406
  this.lastExitCode = chainResult.exitCode;
404
407
  this.lastDuration = (this.lastDuration ?? 0) + chainResult.duration;
405
408
  currentTargetId = (chainResult.exitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
409
+ prevBranch = chainResult.exitCode === 0 ? "onSuccess" : "onFailure";
410
+ prevExit = chainResult.exitCode;
406
411
  }
407
412
  }
408
413
  if (this.runHistory.length > 50) {
package/dist/i18n/en.json CHANGED
@@ -65,7 +65,8 @@
65
65
  "cli.projectAmbiguous": "Multiple projects named \"{name}\" - use the id instead",
66
66
  "cli.projectInvalidColor": "Invalid color \"{color}\". Valid names: {valid}, or a #rrggbb hex",
67
67
  "loop.runHeader": "\n[Run #{runNumber} - {timestamp}]\n",
68
- "loop.chainHeader": "\n--- Chain: {name} ---\n",
68
+ "loop.commandLine": "$ {command}\n",
69
+ "loop.chainHeader": "\n--- Chain: {name} ({branch}, prev exit {prevExit}) ---\n",
69
70
  "loop.exitMarker": "[exit {code} · {duration}]\n",
70
71
  "loop.cwdMissingLog": "[error] working directory does not exist: {cwd}\n",
71
72
  "loop.cwdMissing": "Working directory does not exist: {cwd}",
@@ -190,8 +191,8 @@
190
191
  "board.logModalRunning": "Running",
191
192
  "board.logModalExit": "exit {code}",
192
193
  "board.logModalDuration": "duration",
193
- "board.logModalSearchHint": "type to filter, enter to apply, esc to close",
194
- "board.logModalFooterHints": "/:search c:copy up/down:scroll esc:close",
194
+ "board.logModalSearchHint": "type to filter, enter to apply, esc to exit search",
195
+ "board.logModalFooterHints": "/:search ctrl+x:copy up/down:scroll esc:close",
195
196
  "board.logModalEscClose": "esc to close",
196
197
  "board.logModalRunning": "running…",
197
198
  "board.detailTitle": " Loop Detail ",
@@ -69,9 +69,12 @@ export function CodeEditorModal(props) {
69
69
  redo();
70
70
  return;
71
71
  }
72
- // Ctrl+Ycopy (NOT redo)
73
- if (key.ctrl && input === "y") {
72
+ // Ctrl+Xcut (copy all to clipboard, then clear)
73
+ if (key.ctrl && input === "x") {
74
74
  copyToClipboard(value);
75
+ setValue("");
76
+ setCursorRow(0);
77
+ setCursorCol(0);
75
78
  setFlashMsg(t("codeEditor.copied"));
76
79
  return;
77
80
  }
@@ -90,27 +93,8 @@ export function CodeEditorModal(props) {
90
93
  }
91
94
  return;
92
95
  }
93
- // Shift+Ccopy, Shift+V → paste, Shift+X → clear (button shortcuts)
94
- if (key.shift && !key.ctrl && !key.meta && input === "C") {
95
- copyToClipboard(value);
96
- setFlashMsg(t("codeEditor.copied"));
97
- return;
98
- }
99
- if (key.shift && !key.ctrl && !key.meta && input === "V") {
100
- const clip = readFromClipboard();
101
- if (clip) {
102
- const pasted = sanitizePaste(clip);
103
- if (pasted) {
104
- const next = [...lines];
105
- const line = next[cursorRow] ?? "";
106
- next[cursorRow] =
107
- line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
108
- applyMutation(next, cursorRow, cursorCol + pasted.length);
109
- }
110
- }
111
- return;
112
- }
113
- if (key.shift && !key.ctrl && !key.meta && input === "X") {
96
+ // Ctrl+L → clear all
97
+ if (key.ctrl && input === "l") {
114
98
  setValue("");
115
99
  setCursorRow(0);
116
100
  setCursorCol(0);
@@ -241,5 +225,5 @@ export function CodeEditorModal(props) {
241
225
  return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { children: " " })] }, rowIdx));
242
226
  }
243
227
  return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), segments.map((seg, ti) => (_jsx(Text, { color: seg.color, children: seg.value }, ti)))] }, rowIdx));
244
- }) }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [t("codeEditor.previewLabel"), " "] }), _jsx(Text, { color: theme.text.secondary, children: truncatedPreview || t("codeEditor.emptyPlaceholder") })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonCopy") }), _jsx(Text, { color: theme.text.muted, children: "shift+c" }), _jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonPaste") }), _jsx(Text, { color: theme.text.muted, children: "shift+v" }), _jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonClear") }), _jsx(Text, { color: theme.text.muted, children: "shift+x" })] }), flashMsg ? (_jsx(Text, { color: theme.semantic.success, children: flashMsg })) : null] }), _jsx(Box, { children: _jsx(Text, { color: theme.text.muted, children: t("codeEditor.hint") }) })] }) }));
228
+ }) }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [t("codeEditor.previewLabel"), " "] }), _jsx(Text, { color: theme.text.secondary, children: truncatedPreview || t("codeEditor.emptyPlaceholder") })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonCopy") }), _jsx(Text, { color: theme.text.muted, children: "ctrl+x" }), _jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonPaste") }), _jsx(Text, { color: theme.text.muted, children: "ctrl+v" }), _jsx(Text, { color: theme.accent.brand, children: t("codeEditor.buttonClear") }), _jsx(Text, { color: theme.text.muted, children: "ctrl+l" })] }), flashMsg ? (_jsx(Text, { color: theme.semantic.success, children: flashMsg })) : null] }), _jsx(Box, { children: _jsx(Text, { color: theme.text.muted, children: t("codeEditor.hint") }) })] }) }));
245
229
  }
@@ -1,8 +1,7 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { darkTheme as theme } from "../theme.js";
5
- import { Modal } from "./Modal.js";
6
5
  import { t } from "../../i18n/index.js";
7
6
  import { streamRunLog } from "../daemon.js";
8
7
  import { formatDate } from "../format.js";
@@ -11,6 +10,8 @@ const MAX_VISIBLE_LINES = 20;
11
10
  function colorForLine(line, run) {
12
11
  if (line.includes("[Run #"))
13
12
  return theme.accent.loop;
13
+ if (line.startsWith("$ "))
14
+ return "#f0abfc";
14
15
  if (line.includes("--- Chain:"))
15
16
  return theme.accent.task;
16
17
  if (line.trimStart().startsWith("[exit")) {
@@ -67,7 +68,7 @@ export function LogModal(props) {
67
68
  return;
68
69
  }
69
70
  if (key.escape) {
70
- // Escape handled by App's global popLayer(); no-op here to avoid double-fire
71
+ props.onClose();
71
72
  return;
72
73
  }
73
74
  if (input === "/") {
@@ -75,7 +76,7 @@ export function LogModal(props) {
75
76
  setSearchQuery("");
76
77
  return;
77
78
  }
78
- if (input === "c" && !key.ctrl) {
79
+ if (key.ctrl && input === "x") {
79
80
  copyToClipboard(lines.join("\n"));
80
81
  props.onCopy?.();
81
82
  return;
@@ -102,20 +103,18 @@ export function LogModal(props) {
102
103
  : props.run.exitCode === 0
103
104
  ? theme.semantic.success
104
105
  : theme.semantic.danger;
105
- return (_jsxs(Modal, { title: `Run #${props.run.runNumber} - ${formatDate(props.run.startedAt)}`, onClose: props.onClose, width: "95%", height: "70%", children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " ", props.run.status === "running"
106
- ? t("board.logModalRunning")
107
- : t("board.logModalExit", { code: props.run.exitCode })] }), props.run.duration > 0 ? (_jsxs(Text, { color: theme.text.muted, children: [" ", t("board.logModalDuration"), " ", props.run.duration, "ms"] })) : null, _jsx(Text, { color: theme.text.muted, children: searchMode
108
- ? `/${searchQuery}`
109
- : follow
110
- ? "f follow"
111
- : `[${startIdx}-${endIdx}/${totalLines}]` })] }), searchMode ? (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.text.muted, children: "Search: " }), _jsxs(Text, { color: theme.text.primary, children: [searchQuery, "_"] })] })) : null, _jsx(Box, { flexDirection: "column", children: visible.length === 0 ? (_jsx(Text, { color: theme.text.muted, children: isLoading
112
- ? t("board.logModalLoading")
113
- : searchQuery
114
- ? t("board.logModalNoMatches")
115
- : t("board.logModalEmpty") })) : (visible.map((line, i) => {
116
- const realIdx = startIdx + i;
117
- return (_jsx(Text, { color: colorForLine(line, props.run), wrap: "truncate", children: line }, realIdx));
118
- })) }), _jsx(Box, { marginTop: 1, justifyContent: "space-between", children: _jsx(Text, { color: theme.text.muted, children: searchMode
119
- ? t("board.logModalSearchHint")
120
- : t("board.logModalFooterHints") }) })] }));
106
+ return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { borderStyle: "round", borderColor: theme.accent.brand, backgroundColor: theme.bg.elevated, paddingX: 2, paddingY: 1, flexDirection: "column", width: "95%", height: "70%", children: [_jsx(Text, { color: theme.accent.brand, bold: true, children: `Run #${props.run.runNumber} - ${formatDate(props.run.startedAt)}` }), _jsxs(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " ", props.run.status === "running"
107
+ ? t("board.logModalRunning")
108
+ : t("board.logModalExit", { code: props.run.exitCode })] }), props.run.duration > 0 ? (_jsxs(Text, { color: theme.text.muted, children: [" ", t("board.logModalDuration"), " ", props.run.duration, "ms"] })) : null, _jsx(Text, { color: theme.text.muted, children: searchMode
109
+ ? `/${searchQuery}`
110
+ : `[${startIdx}-${endIdx}/${totalLines}]` })] }), searchMode ? (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.text.muted, children: "Search: " }), _jsxs(Text, { color: theme.text.primary, children: [searchQuery, "_"] })] })) : null, _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visible.length === 0 ? (_jsx(Text, { color: theme.text.muted, children: isLoading
111
+ ? t("board.logModalLoading")
112
+ : searchQuery
113
+ ? t("board.logModalNoMatches")
114
+ : t("board.logModalEmpty") })) : (visible.map((line, i) => {
115
+ const realIdx = startIdx + i;
116
+ return (_jsx(Text, { color: colorForLine(line, props.run), wrap: "truncate", children: line }, realIdx));
117
+ })) }), _jsx(Box, { marginTop: 1, justifyContent: "space-between", children: _jsx(Text, { color: theme.text.muted, children: searchMode
118
+ ? t("board.logModalSearchHint")
119
+ : t("board.logModalFooterHints") }) })] })] }) }));
121
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.4",
3
+ "version": "2.0.7",
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": {