loop-task 2.0.3 → 2.0.5
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.
- package/dist/config/constants.js +10 -0
- package/dist/core/command-runner.js +7 -0
- package/dist/core/loop-controller.js +6 -1
- package/dist/i18n/en.json +14 -1
- package/dist/loop-config.js +59 -0
- package/dist/shared/useUndoRedo.js +79 -0
- package/dist/tui/components/CodeEditorModal.js +229 -0
- package/dist/tui/components/CodeEditorPreview.js +14 -0
- package/dist/tui/components/CreateForm.js +12 -10
- package/dist/tui/components/LogModal.js +2 -0
- package/dist/tui/components/TaskForm.js +12 -12
- package/dist/tui/utils/syntax.js +128 -0
- package/package.json +1 -1
- package/dist/tui/components/InlineCommandEditor.js +0 -143
package/dist/config/constants.js
CHANGED
|
@@ -81,3 +81,13 @@ export const HTTP_API_HOST = "127.0.0.1";
|
|
|
81
81
|
export const EXPORT_MAX_PREVIEW_LINES = 200;
|
|
82
82
|
export const CTRL_SHORTCUT_EDIT = "Ctrl+E";
|
|
83
83
|
export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
|
|
84
|
+
// ── Code editor constants ──────────────────────────────────────────
|
|
85
|
+
export const CODE_EDITOR_MAX_VISIBLE = 2;
|
|
86
|
+
export const CODE_EDITOR_MODAL_HEIGHT = 20;
|
|
87
|
+
export const CODE_EDITOR_UNDO_LIMIT = 50;
|
|
88
|
+
export const CODE_EDITOR_SYNTAX_COLORS = {
|
|
89
|
+
flag: "#38bdf8",
|
|
90
|
+
string: "#4ade80",
|
|
91
|
+
operator: "#facc15",
|
|
92
|
+
word: "#e5e7eb",
|
|
93
|
+
};
|
|
@@ -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))].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)].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.
|
|
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}",
|
|
@@ -276,6 +277,18 @@
|
|
|
276
277
|
"cmdEditor.hint": "enter: new line . ctrl+s: save . esc: cancel",
|
|
277
278
|
"cmdEditor.lines": "lines",
|
|
278
279
|
"cmdEditor.inlineHint": "enter: new line . ctrl+v: paste . ctrl+y: copy . tab: next field . ctrl+s: save",
|
|
280
|
+
"codeEditor.title": "Command Editor",
|
|
281
|
+
"codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo",
|
|
282
|
+
"codeEditor.buttonCopy": "Copy",
|
|
283
|
+
"codeEditor.buttonPaste": "Paste",
|
|
284
|
+
"codeEditor.buttonClear": "Clear",
|
|
285
|
+
"codeEditor.buttonSave": "Save",
|
|
286
|
+
"codeEditor.previewLabel": "Will execute:",
|
|
287
|
+
"codeEditor.previewTruncated": "…",
|
|
288
|
+
"codeEditor.emptyPlaceholder": "Enter command...",
|
|
289
|
+
"codeEditor.fieldHint": "press enter to open editor",
|
|
290
|
+
"codeEditor.copied": "Copied!",
|
|
291
|
+
"codeEditor.cleared": "Cleared",
|
|
279
292
|
"board.exampleInterval": "30m",
|
|
280
293
|
"board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
|
|
281
294
|
"board.exampleDescription": "translate missing strings",
|
package/dist/loop-config.js
CHANGED
|
@@ -54,6 +54,65 @@ export function parseCommandLine(input) {
|
|
|
54
54
|
}
|
|
55
55
|
return tokens;
|
|
56
56
|
}
|
|
57
|
+
export function joinCommandLines(text) {
|
|
58
|
+
// Quote-aware split: don't split on newlines inside quoted strings
|
|
59
|
+
const segments = [];
|
|
60
|
+
let current = "";
|
|
61
|
+
let quote = null;
|
|
62
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
63
|
+
const char = text[i];
|
|
64
|
+
if (quote) {
|
|
65
|
+
if (char === quote) {
|
|
66
|
+
quote = null;
|
|
67
|
+
}
|
|
68
|
+
else if (char === "\\" && quote === '"' && i + 1 < text.length) {
|
|
69
|
+
current += char + text[i + 1];
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
current += char;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (char === '"' || char === "'") {
|
|
77
|
+
quote = char;
|
|
78
|
+
current += char;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === "\n") {
|
|
82
|
+
segments.push(current);
|
|
83
|
+
current = "";
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
current += char;
|
|
87
|
+
}
|
|
88
|
+
segments.push(current);
|
|
89
|
+
// Join segments according to backslash-continuation rules:
|
|
90
|
+
// - Empty/whitespace-only segments are dropped
|
|
91
|
+
// - Segment ending with \: backslash consumed, joins to next with NO added space
|
|
92
|
+
// - Segment NOT ending with \: joins to next with a single space
|
|
93
|
+
// Leading whitespace on each line is trimmed (indentation); trailing whitespace
|
|
94
|
+
// before \ is preserved (it's part of the content — e.g. separates tokens).
|
|
95
|
+
let result = "";
|
|
96
|
+
let prevContinued = false; // true if previous segment ended with \
|
|
97
|
+
for (const seg of segments) {
|
|
98
|
+
const trimmed = seg.trim();
|
|
99
|
+
if (trimmed === "")
|
|
100
|
+
continue;
|
|
101
|
+
const endsBackslash = trimmed.endsWith("\\");
|
|
102
|
+
const content = endsBackslash ? trimmed.slice(0, -1) : trimmed;
|
|
103
|
+
if (result === "") {
|
|
104
|
+
result = content;
|
|
105
|
+
}
|
|
106
|
+
else if (prevContinued) {
|
|
107
|
+
result += content;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
result += " " + content;
|
|
111
|
+
}
|
|
112
|
+
prevContinued = endsBackslash;
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
57
116
|
export function buildLoopOptions(intervalHuman, input = {}) {
|
|
58
117
|
const command = input.command ?? "";
|
|
59
118
|
const commandArgs = input.commandArgs ?? [];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Pure undo/redo stack logic — exported for direct unit testing
|
|
4
|
+
* without needing React DOM or renderHook.
|
|
5
|
+
*/
|
|
6
|
+
export class UndoRedoStack {
|
|
7
|
+
value;
|
|
8
|
+
limit;
|
|
9
|
+
history = [];
|
|
10
|
+
future = [];
|
|
11
|
+
constructor(value, limit = 50) {
|
|
12
|
+
this.value = value;
|
|
13
|
+
this.limit = limit;
|
|
14
|
+
}
|
|
15
|
+
get canUndo() {
|
|
16
|
+
return this.history.length > 0;
|
|
17
|
+
}
|
|
18
|
+
get canRedo() {
|
|
19
|
+
return this.future.length > 0;
|
|
20
|
+
}
|
|
21
|
+
setValue(next) {
|
|
22
|
+
if (next === this.value)
|
|
23
|
+
return false;
|
|
24
|
+
this.history.push(this.value);
|
|
25
|
+
if (this.history.length > this.limit) {
|
|
26
|
+
this.history.shift();
|
|
27
|
+
}
|
|
28
|
+
this.future = [];
|
|
29
|
+
this.value = next;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
undo() {
|
|
33
|
+
if (this.history.length === 0)
|
|
34
|
+
return false;
|
|
35
|
+
this.future.push(this.value);
|
|
36
|
+
this.value = this.history.pop();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
redo() {
|
|
40
|
+
if (this.future.length === 0)
|
|
41
|
+
return false;
|
|
42
|
+
this.history.push(this.value);
|
|
43
|
+
this.value = this.future.pop();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function useUndoRedo(initial, limit = 50) {
|
|
48
|
+
const [value, setValueState] = useState(initial);
|
|
49
|
+
const stackRef = useRef(new UndoRedoStack(initial, limit));
|
|
50
|
+
const setValue = useCallback((next) => {
|
|
51
|
+
setValueState((prev) => {
|
|
52
|
+
if (stackRef.current.setValue(next))
|
|
53
|
+
return next;
|
|
54
|
+
return prev;
|
|
55
|
+
});
|
|
56
|
+
}, [limit]);
|
|
57
|
+
const undo = useCallback(() => {
|
|
58
|
+
setValueState((prev) => {
|
|
59
|
+
if (stackRef.current.undo())
|
|
60
|
+
return stackRef.current.value;
|
|
61
|
+
return prev;
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
const redo = useCallback(() => {
|
|
65
|
+
setValueState((prev) => {
|
|
66
|
+
if (stackRef.current.redo())
|
|
67
|
+
return stackRef.current.value;
|
|
68
|
+
return prev;
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
return {
|
|
72
|
+
value,
|
|
73
|
+
setValue,
|
|
74
|
+
undo,
|
|
75
|
+
redo,
|
|
76
|
+
canUndo: stackRef.current.canUndo,
|
|
77
|
+
canRedo: stackRef.current.canRedo,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useCallback, useMemo } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { darkTheme as theme } from "../theme.js";
|
|
5
|
+
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { useUndoRedo } from "../../shared/useUndoRedo.js";
|
|
7
|
+
import { highlightSegments } from "../utils/syntax.js";
|
|
8
|
+
import { joinCommandLines } from "../../loop-config.js";
|
|
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;
|
|
15
|
+
export function CodeEditorModal(props) {
|
|
16
|
+
const { initialValue, onSave, onCancel } = props;
|
|
17
|
+
const { value, setValue, undo, redo } = useUndoRedo(initialValue, CODE_EDITOR_UNDO_LIMIT);
|
|
18
|
+
const [cursorRow, setCursorRow] = useState(() => {
|
|
19
|
+
const lines = initialValue.split("\n");
|
|
20
|
+
return Math.max(0, lines.length - 1);
|
|
21
|
+
});
|
|
22
|
+
const [cursorCol, setCursorCol] = useState(() => {
|
|
23
|
+
const lines = initialValue.split("\n");
|
|
24
|
+
return (lines[lines.length - 1] ?? "").length;
|
|
25
|
+
});
|
|
26
|
+
const [flashMsg, setFlashMsg] = useState(null);
|
|
27
|
+
const lines = useMemo(() => (value ? value.split("\n") : [""]), [value]);
|
|
28
|
+
const lineCount = lines.length;
|
|
29
|
+
const lineNumWidth = String(lineCount).length;
|
|
30
|
+
// Flash message auto-clear
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
if (flashMsg) {
|
|
33
|
+
const timer = setTimeout(() => setFlashMsg(null), 1500);
|
|
34
|
+
return () => clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}, [flashMsg]);
|
|
37
|
+
// Scroll: keep cursor visible in the editor area
|
|
38
|
+
const scrollStart = useMemo(() => {
|
|
39
|
+
if (cursorRow < EDITOR_VISIBLE_LINES)
|
|
40
|
+
return 0;
|
|
41
|
+
return cursorRow - EDITOR_VISIBLE_LINES + 1;
|
|
42
|
+
}, [cursorRow]);
|
|
43
|
+
const visibleLines = useMemo(() => lines.slice(scrollStart, scrollStart + EDITOR_VISIBLE_LINES), [lines, scrollStart]);
|
|
44
|
+
// ---- Helper: apply a text mutation via setValue and update cursor ----
|
|
45
|
+
const applyMutation = useCallback((nextLines, newCursorRow, newCursorCol) => {
|
|
46
|
+
setValue(nextLines.join("\n"));
|
|
47
|
+
setCursorRow(newCursorRow);
|
|
48
|
+
setCursorCol(newCursorCol);
|
|
49
|
+
}, [setValue]);
|
|
50
|
+
// ---- Keyboard handling ----
|
|
51
|
+
useInput((input, key) => {
|
|
52
|
+
// Esc → cancel
|
|
53
|
+
if (key.escape) {
|
|
54
|
+
onCancel();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Ctrl+S → save
|
|
58
|
+
if (key.ctrl && input === "s") {
|
|
59
|
+
onSave(value);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Ctrl+Z → undo
|
|
63
|
+
if (key.ctrl && !key.shift && input === "z") {
|
|
64
|
+
undo();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Ctrl+Shift+Z → redo
|
|
68
|
+
if (key.ctrl && key.shift && input === "z") {
|
|
69
|
+
redo();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Ctrl+X → cut (copy all to clipboard, then clear)
|
|
73
|
+
if (key.ctrl && input === "x") {
|
|
74
|
+
copyToClipboard(value);
|
|
75
|
+
setValue("");
|
|
76
|
+
setCursorRow(0);
|
|
77
|
+
setCursorCol(0);
|
|
78
|
+
setFlashMsg(t("codeEditor.copied"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Ctrl+V → paste from clipboard
|
|
82
|
+
if (key.ctrl && input === "v") {
|
|
83
|
+
const clip = readFromClipboard();
|
|
84
|
+
if (clip) {
|
|
85
|
+
const pasted = sanitizePaste(clip);
|
|
86
|
+
if (pasted) {
|
|
87
|
+
const next = [...lines];
|
|
88
|
+
const line = next[cursorRow] ?? "";
|
|
89
|
+
next[cursorRow] =
|
|
90
|
+
line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
91
|
+
applyMutation(next, cursorRow, cursorCol + pasted.length);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Ctrl+L → clear all
|
|
97
|
+
if (key.ctrl && input === "l") {
|
|
98
|
+
setValue("");
|
|
99
|
+
setCursorRow(0);
|
|
100
|
+
setCursorCol(0);
|
|
101
|
+
setFlashMsg(t("codeEditor.cleared"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Enter → insert newline at cursor
|
|
105
|
+
if (key.return) {
|
|
106
|
+
const next = [...lines];
|
|
107
|
+
const line = next[cursorRow] ?? "";
|
|
108
|
+
next.splice(cursorRow, 1, line.slice(0, cursorCol), line.slice(cursorCol));
|
|
109
|
+
applyMutation(next, cursorRow + 1, 0);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Arrow keys
|
|
113
|
+
if (key.upArrow) {
|
|
114
|
+
if (cursorRow > 0) {
|
|
115
|
+
const newRow = cursorRow - 1;
|
|
116
|
+
setCursorRow(newRow);
|
|
117
|
+
setCursorCol((c) => Math.min(c, (lines[newRow] ?? "").length));
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.downArrow) {
|
|
122
|
+
if (cursorRow < lines.length - 1) {
|
|
123
|
+
const newRow = cursorRow + 1;
|
|
124
|
+
setCursorRow(newRow);
|
|
125
|
+
setCursorCol((c) => Math.min(c, (lines[newRow] ?? "").length));
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (key.leftArrow) {
|
|
130
|
+
if (cursorCol > 0) {
|
|
131
|
+
setCursorCol((c) => c - 1);
|
|
132
|
+
}
|
|
133
|
+
else if (cursorRow > 0) {
|
|
134
|
+
setCursorRow((r) => r - 1);
|
|
135
|
+
setCursorCol((lines[cursorRow - 1] ?? "").length);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key.rightArrow) {
|
|
140
|
+
const curLen = (lines[cursorRow] ?? "").length;
|
|
141
|
+
if (cursorCol < curLen) {
|
|
142
|
+
setCursorCol((c) => c + 1);
|
|
143
|
+
}
|
|
144
|
+
else if (cursorRow < lines.length - 1) {
|
|
145
|
+
setCursorRow((r) => r + 1);
|
|
146
|
+
setCursorCol(0);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Backspace / Delete
|
|
151
|
+
if (key.backspace || key.delete) {
|
|
152
|
+
if (cursorCol > 0) {
|
|
153
|
+
const next = [...lines];
|
|
154
|
+
const line = next[cursorRow] ?? "";
|
|
155
|
+
next[cursorRow] =
|
|
156
|
+
line.slice(0, cursorCol - 1) + line.slice(cursorCol);
|
|
157
|
+
applyMutation(next, cursorRow, cursorCol - 1);
|
|
158
|
+
}
|
|
159
|
+
else if (cursorRow > 0) {
|
|
160
|
+
const next = [...lines];
|
|
161
|
+
const prev = next[cursorRow - 1] ?? "";
|
|
162
|
+
const cur = next[cursorRow] ?? "";
|
|
163
|
+
next.splice(cursorRow - 1, 2, prev + cur);
|
|
164
|
+
applyMutation(next, cursorRow - 1, prev.length);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
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
|
|
184
|
+
if (input.length > 1 && !key.meta) {
|
|
185
|
+
const pasted = sanitizePaste(input);
|
|
186
|
+
if (pasted) {
|
|
187
|
+
const next = [...lines];
|
|
188
|
+
const line = next[cursorRow] ?? "";
|
|
189
|
+
next[cursorRow] =
|
|
190
|
+
line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
191
|
+
applyMutation(next, cursorRow, cursorCol + pasted.length);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Single printable char → insert at cursor
|
|
196
|
+
if (input.length === 1 && input >= " " && input <= "~") {
|
|
197
|
+
const next = [...lines];
|
|
198
|
+
const line = next[cursorRow] ?? "";
|
|
199
|
+
next[cursorRow] =
|
|
200
|
+
line.slice(0, cursorCol) + input + line.slice(cursorCol);
|
|
201
|
+
applyMutation(next, cursorRow, cursorCol + 1);
|
|
202
|
+
}
|
|
203
|
+
}, { isActive: true });
|
|
204
|
+
// ---- Live preview footer ----
|
|
205
|
+
const joined = joinCommandLines(value);
|
|
206
|
+
const innerWidth = 58;
|
|
207
|
+
const truncatedPreview = joined.length > innerWidth
|
|
208
|
+
? joined.slice(0, innerWidth - 1) + t("codeEditor.previewTruncated")
|
|
209
|
+
: joined;
|
|
210
|
+
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) => {
|
|
212
|
+
const rowIdx = scrollStart + visIdx;
|
|
213
|
+
const isCursor = rowIdx === cursorRow;
|
|
214
|
+
const lineNum = String(rowIdx + 1).padStart(lineNumWidth, " ");
|
|
215
|
+
if (isCursor) {
|
|
216
|
+
const before = line.slice(0, cursorCol);
|
|
217
|
+
const cur = cursorCol < line.length ? line[cursorCol] : " ";
|
|
218
|
+
const after = cursorCol < line.length ? line.slice(cursorCol + 1) : "";
|
|
219
|
+
// Cursor line: render with cursor (no syntax highlight on cursor line)
|
|
220
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { color: theme.text.primary, children: before }), _jsx(Text, { inverse: true, children: cur }), _jsx(Text, { color: theme.text.primary, children: after })] }, rowIdx));
|
|
221
|
+
}
|
|
222
|
+
// Non-cursor line: syntax highlighted (whitespace preserved)
|
|
223
|
+
const segments = highlightSegments(line, CODE_EDITOR_SYNTAX_COLORS, theme.text.muted);
|
|
224
|
+
if (segments.length === 0) {
|
|
225
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { children: " " })] }, rowIdx));
|
|
226
|
+
}
|
|
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));
|
|
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") }) })] }) }));
|
|
229
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { darkTheme as theme } from "../theme.js";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { CODE_EDITOR_MAX_VISIBLE } from "../../config/constants.js";
|
|
6
|
+
export function CodeEditorPreview(props) {
|
|
7
|
+
const { value, hint, isActive, onActivate } = props;
|
|
8
|
+
const lines = value ? value.split("\n") : [];
|
|
9
|
+
const visibleLines = lines.slice(0, CODE_EDITOR_MAX_VISIBLE);
|
|
10
|
+
const truncated = lines.length > CODE_EDITOR_MAX_VISIBLE;
|
|
11
|
+
const hasValue = lines.length > 0;
|
|
12
|
+
void onActivate; // consumed by parent via useInput when active
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Box, { borderStyle: "single", borderColor: isActive ? theme.accent.brand : theme.border.dim, backgroundColor: isActive ? theme.bg.input : undefined, paddingLeft: 1, overflow: "hidden", width: "100%", flexDirection: "column", children: hasValue ? (_jsxs(_Fragment, { children: [visibleLines.map((line, i) => (_jsx(Text, { color: theme.text.primary, children: line }, i))), truncated ? (_jsx(Text, { color: theme.text.muted, children: t("codeEditor.previewTruncated") })) : null] })) : (_jsx(Text, { color: theme.text.muted, children: hint })) }), isActive ? (_jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: theme.accent.brand, children: "› " }), _jsx(Text, { color: theme.text.muted, children: t("codeEditor.fieldHint") })] })) : null] }));
|
|
14
|
+
}
|
|
@@ -5,14 +5,16 @@ import { t } from "../../i18n/index.js";
|
|
|
5
5
|
import { WizardForm } from "./WizardForm.js";
|
|
6
6
|
import { TaskPickerModal } from "./TaskPickerModal.js";
|
|
7
7
|
import { SelectModal, SelectValueField } from "./SelectModal.js";
|
|
8
|
-
import {
|
|
8
|
+
import { CodeEditorPreview } from "./CodeEditorPreview.js";
|
|
9
|
+
import { CodeEditorModal } from "./CodeEditorModal.js";
|
|
9
10
|
import { parseDuration } from "../../duration.js";
|
|
10
|
-
import { parseCommandLine } from "../../loop-config.js";
|
|
11
|
+
import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
|
|
11
12
|
// ── Component ───────────────────────────────────────────────────────
|
|
12
13
|
export function CreateView(props) {
|
|
13
14
|
const { mode, editId, initial, selectedTaskId, selectedTaskName, tasks, currentProjectId, onCancel, onDone, onChooseTask, } = props;
|
|
14
15
|
const [taskPickerOpen, setTaskPickerOpen] = useState(false);
|
|
15
16
|
const [openSelect, setOpenSelect] = useState(null);
|
|
17
|
+
const [commandEditorOpen, setCommandEditorOpen] = useState(false);
|
|
16
18
|
const [commandValue, setCommandValue] = useState(initial.command ?? "");
|
|
17
19
|
// renderCustom is invoked on every WizardForm render for every field, so this
|
|
18
20
|
// ref always holds the latest onChange/onAdvance closures per field key —
|
|
@@ -74,11 +76,8 @@ export function CreateView(props) {
|
|
|
74
76
|
inputType: "text",
|
|
75
77
|
defaultValue: commandValue || undefined,
|
|
76
78
|
skip: (values) => !!values.taskMode?.includes("Existing"),
|
|
77
|
-
|
|
78
|
-
renderCustom: ({ isActive
|
|
79
|
-
setCommandValue(v);
|
|
80
|
-
onChange(v);
|
|
81
|
-
} })),
|
|
79
|
+
onActivate: () => setCommandEditorOpen(true),
|
|
80
|
+
renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: commandValue, hint: t("wizard.commandHint"), isActive: isActive, onActivate: () => setCommandEditorOpen(true) })),
|
|
82
81
|
},
|
|
83
82
|
{
|
|
84
83
|
key: "runNow",
|
|
@@ -153,7 +152,7 @@ export function CreateView(props) {
|
|
|
153
152
|
return;
|
|
154
153
|
const cmd = isExistingTask
|
|
155
154
|
? ""
|
|
156
|
-
: cmdValue
|
|
155
|
+
: joinCommandLines(cmdValue);
|
|
157
156
|
let cmdOnly = "";
|
|
158
157
|
let args = [];
|
|
159
158
|
if (cmd.trim()) {
|
|
@@ -212,12 +211,15 @@ export function CreateView(props) {
|
|
|
212
211
|
const selectTitleFor = (field) => field === "taskMode" ? t("wizard.taskModePrompt")
|
|
213
212
|
: field === "runNow" ? t("wizard.runNowPrompt")
|
|
214
213
|
: t("wizard.projectPrompt");
|
|
215
|
-
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
|
|
214
|
+
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
|
|
216
215
|
onChooseTask({ id: task.id, name: task.name });
|
|
217
216
|
setTaskPickerOpen(false);
|
|
218
217
|
}, onClose: () => setTaskPickerOpen(false) })) : null, openSelect ? (_jsx(SelectModal, { title: selectTitleFor(openSelect), options: selectOptionsFor(openSelect), initialValue: fieldCallbacksRef.current[openSelect]?.value, onSelect: (option) => {
|
|
219
218
|
fieldCallbacksRef.current[openSelect]?.onChange(option.value);
|
|
220
219
|
fieldCallbacksRef.current[openSelect]?.onAdvance();
|
|
221
220
|
setOpenSelect(null);
|
|
222
|
-
}, onClose: () => setOpenSelect(null) })) : null
|
|
221
|
+
}, onClose: () => setOpenSelect(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
|
|
222
|
+
setCommandValue(v);
|
|
223
|
+
setCommandEditorOpen(false);
|
|
224
|
+
}, onCancel: () => setCommandEditorOpen(false) })) : null] }));
|
|
223
225
|
}
|
|
@@ -11,6 +11,8 @@ const MAX_VISIBLE_LINES = 20;
|
|
|
11
11
|
function colorForLine(line, run) {
|
|
12
12
|
if (line.includes("[Run #"))
|
|
13
13
|
return theme.accent.loop;
|
|
14
|
+
if (line.startsWith("$ "))
|
|
15
|
+
return "#f0abfc";
|
|
14
16
|
if (line.includes("--- Chain:"))
|
|
15
17
|
return theme.accent.task;
|
|
16
18
|
if (line.trimStart().startsWith("[exit")) {
|
|
@@ -2,10 +2,12 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useMemo, useCallback, useState, useRef } from "react";
|
|
3
3
|
import { WizardForm } from "./WizardForm.js";
|
|
4
4
|
import { SelectModal, SelectValueField } from "./SelectModal.js";
|
|
5
|
-
import {
|
|
5
|
+
import { CodeEditorPreview } from "./CodeEditorPreview.js";
|
|
6
|
+
import { CodeEditorModal } from "./CodeEditorModal.js";
|
|
6
7
|
import { createTask, updateTask, listTasks } from "../daemon.js";
|
|
7
8
|
import crypto from "node:crypto";
|
|
8
9
|
import { t } from "../../i18n/index.js";
|
|
10
|
+
import { joinCommandLines } from "../../loop-config.js";
|
|
9
11
|
// ── Utility ─────────────────────────────────────────────────────────
|
|
10
12
|
function parseArgs(cmd) {
|
|
11
13
|
const tokens = [];
|
|
@@ -29,6 +31,7 @@ export function TaskForm(props) {
|
|
|
29
31
|
}, []);
|
|
30
32
|
const chainOptions = useMemo(() => [t("wizard.chainNone"), ...tasks.map((task) => task.name)], [tasks]);
|
|
31
33
|
const chainSelectOptions = useMemo(() => chainOptions.map((v) => ({ value: v, label: v })), [chainOptions]);
|
|
34
|
+
const [commandEditorOpen, setCommandEditorOpen] = useState(false);
|
|
32
35
|
const [openChainField, setOpenChainField] = useState(null);
|
|
33
36
|
const chainFieldsRef = useRef({});
|
|
34
37
|
const resolveChainId = useCallback((val) => {
|
|
@@ -60,10 +63,8 @@ export function TaskForm(props) {
|
|
|
60
63
|
required: true,
|
|
61
64
|
inputType: "text",
|
|
62
65
|
defaultValue: commandValue || undefined,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
onChange(v);
|
|
66
|
-
} })),
|
|
66
|
+
onActivate: () => setCommandEditorOpen(true),
|
|
67
|
+
renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: commandValue, hint: t("wizard.commandHint"), isActive: isActive, onActivate: () => setCommandEditorOpen(true) })),
|
|
67
68
|
},
|
|
68
69
|
{
|
|
69
70
|
key: "onSuccess",
|
|
@@ -94,11 +95,7 @@ export function TaskForm(props) {
|
|
|
94
95
|
}, [commandValue, chainOptions, editTask, resolveChainName]);
|
|
95
96
|
const handleComplete = useCallback((values) => {
|
|
96
97
|
const name = values.name?.trim() ?? "";
|
|
97
|
-
const rawCommand = commandValue
|
|
98
|
-
.split("\n")
|
|
99
|
-
.map((l) => l.trim())
|
|
100
|
-
.filter(Boolean)
|
|
101
|
-
.join(" ");
|
|
98
|
+
const rawCommand = joinCommandLines(commandValue);
|
|
102
99
|
if (!name || !rawCommand)
|
|
103
100
|
return;
|
|
104
101
|
const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
|
|
@@ -126,9 +123,12 @@ export function TaskForm(props) {
|
|
|
126
123
|
const title = mode === "edit"
|
|
127
124
|
? t("board.taskEditTitle")
|
|
128
125
|
: t("board.taskCreateTitle");
|
|
129
|
-
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
|
|
126
|
+
return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
|
|
130
127
|
chainFieldsRef.current[openChainField]?.onChange(option.value);
|
|
131
128
|
chainFieldsRef.current[openChainField]?.onAdvance();
|
|
132
129
|
setOpenChainField(null);
|
|
133
|
-
}, onClose: () => setOpenChainField(null) })) : null
|
|
130
|
+
}, onClose: () => setOpenChainField(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
|
|
131
|
+
setCommandValue(v);
|
|
132
|
+
setCommandEditorOpen(false);
|
|
133
|
+
}, onCancel: () => setCommandEditorOpen(false) })) : null] }));
|
|
134
134
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const OPERATORS = new Set(["|", "&&", "||", ";", ">", ">>", "<"]);
|
|
2
|
+
/**
|
|
3
|
+
* Scan a command line into tokens, preserving whitespace runs as
|
|
4
|
+
* "whitespace" tokens. Used by highlightSegments for rendering.
|
|
5
|
+
*/
|
|
6
|
+
function scanTokens(line) {
|
|
7
|
+
if (!line)
|
|
8
|
+
return [];
|
|
9
|
+
const tokens = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
const len = line.length;
|
|
12
|
+
while (i < len) {
|
|
13
|
+
// Whitespace run — preserve it as a token
|
|
14
|
+
if (line[i] === " " || line[i] === "\t") {
|
|
15
|
+
let j = i;
|
|
16
|
+
while (j < len && (line[j] === " " || line[j] === "\t"))
|
|
17
|
+
j++;
|
|
18
|
+
tokens.push({ type: "whitespace", value: line.slice(i, j) });
|
|
19
|
+
i = j;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Try to match multi-char operators first (&&, ||, >>)
|
|
23
|
+
let opMatch;
|
|
24
|
+
for (const op of OPERATORS) {
|
|
25
|
+
if (op.length > 1 && line.slice(i, i + op.length) === op) {
|
|
26
|
+
opMatch = op;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (opMatch) {
|
|
31
|
+
tokens.push({ type: "operator", value: opMatch });
|
|
32
|
+
i += opMatch.length;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Try to match single-char operators
|
|
36
|
+
if (OPERATORS.has(line[i])) {
|
|
37
|
+
tokens.push({ type: "operator", value: line[i] });
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// Quoted string
|
|
42
|
+
if (line[i] === '"' || line[i] === "'") {
|
|
43
|
+
const quote = line[i];
|
|
44
|
+
let j = i + 1;
|
|
45
|
+
while (j < len && line[j] !== quote) {
|
|
46
|
+
// Handle escaped quote
|
|
47
|
+
if (line[j] === "\\" && j + 1 < len) {
|
|
48
|
+
j += 2;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Include closing quote if found
|
|
55
|
+
if (j < len && line[j] === quote)
|
|
56
|
+
j++;
|
|
57
|
+
tokens.push({ type: "string", value: line.slice(i, j) });
|
|
58
|
+
i = j;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Unquoted word — consume until whitespace or operator
|
|
62
|
+
let j = i;
|
|
63
|
+
while (j < len) {
|
|
64
|
+
if (line[j] === " " || line[j] === "\t")
|
|
65
|
+
break;
|
|
66
|
+
// Check for multi-char operator boundary
|
|
67
|
+
let isOpBoundary = false;
|
|
68
|
+
for (const op of OPERATORS) {
|
|
69
|
+
if (op.length > 1 && line.slice(j, j + op.length) === op) {
|
|
70
|
+
isOpBoundary = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (isOpBoundary)
|
|
75
|
+
break;
|
|
76
|
+
// Check for single-char operator boundary
|
|
77
|
+
if (OPERATORS.has(line[j])) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
// Check for quoted string start
|
|
81
|
+
if (line[j] === '"' || line[j] === "'")
|
|
82
|
+
break;
|
|
83
|
+
j++;
|
|
84
|
+
}
|
|
85
|
+
const raw = line.slice(i, j);
|
|
86
|
+
tokens.push(classifyWord(raw));
|
|
87
|
+
i = j;
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Tokenize a command line for syntax highlighting purposes.
|
|
93
|
+
* This is a cosmetic tokenizer — the real parsing for execution
|
|
94
|
+
* lives in src/loop-config.ts parseCommandLine.
|
|
95
|
+
*
|
|
96
|
+
* Whitespace is not returned (use highlightSegments for rendering
|
|
97
|
+
* that needs to preserve spacing).
|
|
98
|
+
*/
|
|
99
|
+
export function tokenizeCommand(line) {
|
|
100
|
+
return scanTokens(line).filter((tok) => tok.type !== "whitespace");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Produce colored segments for a command line, preserving whitespace
|
|
104
|
+
* exactly so token-by-token rendering does not collapse spaces.
|
|
105
|
+
*/
|
|
106
|
+
export function highlightSegments(line, colors, whitespaceColor) {
|
|
107
|
+
return scanTokens(line).map((tok) => ({
|
|
108
|
+
value: tok.value,
|
|
109
|
+
color: tok.type === "whitespace" ? whitespaceColor : colors[tok.type],
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
function classifyWord(value) {
|
|
113
|
+
// Flag: --something (long flag)
|
|
114
|
+
if (value.startsWith("--") && value.length > 2) {
|
|
115
|
+
return { type: "flag", value };
|
|
116
|
+
}
|
|
117
|
+
// Flag: -f (single letter flag, not negative number like -1)
|
|
118
|
+
if (value.startsWith("-") &&
|
|
119
|
+
value.length === 2 &&
|
|
120
|
+
/[a-zA-Z]/.test(value[1])) {
|
|
121
|
+
return { type: "flag", value };
|
|
122
|
+
}
|
|
123
|
+
// Flag: -abc (combined short flags like -xyz)
|
|
124
|
+
if (value.startsWith("-") && value.length > 1 && /^[a-zA-Z]+$/.test(value.slice(1))) {
|
|
125
|
+
return { type: "flag", value };
|
|
126
|
+
}
|
|
127
|
+
return { type: "word", value };
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
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": {
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback } from "react";
|
|
3
|
-
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import { darkTheme as theme } from "../theme.js";
|
|
5
|
-
import { t } from "../../i18n/index.js";
|
|
6
|
-
import { copyToClipboard } from "../../shared/clipboard.js";
|
|
7
|
-
import { sanitizePaste } from "../utils/paste.js";
|
|
8
|
-
const MAX_VISIBLE = 8;
|
|
9
|
-
export function InlineCommandEditor({ value, hint, isActive, onChange, }) {
|
|
10
|
-
const [lines, setLines] = useState(() => value ? value.split("\n") : [""]);
|
|
11
|
-
const [cursorRow, setCursorRow] = useState(0);
|
|
12
|
-
const [cursorCol, setCursorCol] = useState(0);
|
|
13
|
-
const emit = useCallback((nextLines) => {
|
|
14
|
-
onChange(nextLines.join("\n"));
|
|
15
|
-
}, [onChange]);
|
|
16
|
-
useInput((input, key) => {
|
|
17
|
-
// Ctrl+Y: copy full command text
|
|
18
|
-
if (key.ctrl && input === "y") {
|
|
19
|
-
copyToClipboard(value);
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
// Let WizardForm handle these
|
|
23
|
-
if (key.ctrl || key.escape || key.tab)
|
|
24
|
-
return;
|
|
25
|
-
if (key.return) {
|
|
26
|
-
const next = [...lines];
|
|
27
|
-
const line = next[cursorRow] ?? "";
|
|
28
|
-
next.splice(cursorRow, 1, line.slice(0, cursorCol), line.slice(cursorCol));
|
|
29
|
-
setLines(next);
|
|
30
|
-
setCursorRow((r) => r + 1);
|
|
31
|
-
setCursorCol(0);
|
|
32
|
-
emit(next);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (key.upArrow) {
|
|
36
|
-
if (cursorRow > 0) {
|
|
37
|
-
setCursorRow((r) => r - 1);
|
|
38
|
-
setCursorCol((c) => Math.min(c, (lines[cursorRow - 1] ?? "").length));
|
|
39
|
-
}
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (key.downArrow) {
|
|
43
|
-
if (cursorRow < lines.length - 1) {
|
|
44
|
-
setCursorRow((r) => r + 1);
|
|
45
|
-
setCursorCol((c) => Math.min(c, (lines[cursorRow + 1] ?? "").length));
|
|
46
|
-
}
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (key.leftArrow) {
|
|
50
|
-
if (cursorCol > 0)
|
|
51
|
-
setCursorCol((c) => c - 1);
|
|
52
|
-
else if (cursorRow > 0) {
|
|
53
|
-
setCursorRow((r) => r - 1);
|
|
54
|
-
setCursorCol((lines[cursorRow - 1] ?? "").length);
|
|
55
|
-
}
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (key.rightArrow) {
|
|
59
|
-
const curLen = (lines[cursorRow] ?? "").length;
|
|
60
|
-
if (cursorCol < curLen)
|
|
61
|
-
setCursorCol((c) => c + 1);
|
|
62
|
-
else if (cursorRow < lines.length - 1) {
|
|
63
|
-
setCursorRow((r) => r + 1);
|
|
64
|
-
setCursorCol(0);
|
|
65
|
-
}
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (key.backspace || key.delete) {
|
|
69
|
-
if (cursorCol > 0) {
|
|
70
|
-
const next = [...lines];
|
|
71
|
-
const line = next[cursorRow] ?? "";
|
|
72
|
-
next[cursorRow] = line.slice(0, cursorCol - 1) + line.slice(cursorCol);
|
|
73
|
-
setLines(next);
|
|
74
|
-
setCursorCol((c) => c - 1);
|
|
75
|
-
emit(next);
|
|
76
|
-
}
|
|
77
|
-
else if (cursorRow > 0) {
|
|
78
|
-
const next = [...lines];
|
|
79
|
-
const prev = next[cursorRow - 1] ?? "";
|
|
80
|
-
const cur = next[cursorRow] ?? "";
|
|
81
|
-
next.splice(cursorRow - 1, 2, prev + cur);
|
|
82
|
-
setLines(next);
|
|
83
|
-
setCursorRow((r) => r - 1);
|
|
84
|
-
setCursorCol(prev.length);
|
|
85
|
-
emit(next);
|
|
86
|
-
}
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
|
|
90
|
-
if (input.includes("\x1b[200~")) {
|
|
91
|
-
const pasted = sanitizePaste(input);
|
|
92
|
-
if (pasted) {
|
|
93
|
-
const next = [...lines];
|
|
94
|
-
const line = next[cursorRow] ?? "";
|
|
95
|
-
next[cursorRow] = line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
96
|
-
setLines(next);
|
|
97
|
-
setCursorCol((c) => c + pasted.length);
|
|
98
|
-
emit(next);
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
// Multi-char containing CR/LF with no bracketed markers — ignore
|
|
103
|
-
if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
|
|
104
|
-
return;
|
|
105
|
-
// Multi-char printable input = unbracketed single-line paste (e.g. right-click)
|
|
106
|
-
if (input.length > 1 && !key.meta) {
|
|
107
|
-
const pasted = sanitizePaste(input);
|
|
108
|
-
if (pasted) {
|
|
109
|
-
const next = [...lines];
|
|
110
|
-
const line = next[cursorRow] ?? "";
|
|
111
|
-
next[cursorRow] = line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
112
|
-
setLines(next);
|
|
113
|
-
setCursorCol((c) => c + pasted.length);
|
|
114
|
-
emit(next);
|
|
115
|
-
}
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (input.length === 1 && input >= " " && input <= "~") {
|
|
119
|
-
const next = [...lines];
|
|
120
|
-
const line = next[cursorRow] ?? "";
|
|
121
|
-
next[cursorRow] = line.slice(0, cursorCol) + input + line.slice(cursorCol);
|
|
122
|
-
setLines(next);
|
|
123
|
-
setCursorCol((c) => c + 1);
|
|
124
|
-
emit(next);
|
|
125
|
-
}
|
|
126
|
-
}, { isActive });
|
|
127
|
-
const scrollStart = Math.max(0, cursorRow - MAX_VISIBLE + 1);
|
|
128
|
-
const visible = lines.slice(scrollStart, scrollStart + MAX_VISIBLE);
|
|
129
|
-
const lineNumWidth = String(lines.length).length;
|
|
130
|
-
const isEmpty = lines.length === 1 && lines[0] === "";
|
|
131
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: isActive ? theme.accent.brand : theme.border.dim, backgroundColor: isActive ? theme.bg.input : undefined, width: "100%", paddingX: 1, children: [isEmpty && !isActive ? (_jsx(Text, { color: theme.text.muted, children: hint })) : (visible.map((line, visIdx) => {
|
|
132
|
-
const rowIdx = scrollStart + visIdx;
|
|
133
|
-
const isCursor = isActive && rowIdx === cursorRow;
|
|
134
|
-
const lineNum = String(rowIdx + 1).padStart(lineNumWidth, " ");
|
|
135
|
-
if (isCursor) {
|
|
136
|
-
const before = line.slice(0, cursorCol);
|
|
137
|
-
const cur = cursorCol < line.length ? line[cursorCol] : " ";
|
|
138
|
-
const after = cursorCol < line.length ? line.slice(cursorCol + 1) : "";
|
|
139
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { color: theme.text.primary, children: before }), _jsx(Text, { inverse: true, children: cur }), _jsx(Text, { color: theme.text.primary, children: after })] }, rowIdx));
|
|
140
|
-
}
|
|
141
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { color: theme.text.secondary, children: line })] }, rowIdx));
|
|
142
|
-
})), isActive ? (_jsx(Box, { marginTop: 0, children: _jsx(Text, { color: theme.text.muted, children: t("cmdEditor.inlineHint") }) })) : null] }));
|
|
143
|
-
}
|