loop-task 2.0.3 → 2.0.4

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.
@@ -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
+ };
package/dist/i18n/en.json CHANGED
@@ -276,6 +276,18 @@
276
276
  "cmdEditor.hint": "enter: new line . ctrl+s: save . esc: cancel",
277
277
  "cmdEditor.lines": "lines",
278
278
  "cmdEditor.inlineHint": "enter: new line . ctrl+v: paste . ctrl+y: copy . tab: next field . ctrl+s: save",
279
+ "codeEditor.title": "Command Editor",
280
+ "codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo",
281
+ "codeEditor.buttonCopy": "Copy",
282
+ "codeEditor.buttonPaste": "Paste",
283
+ "codeEditor.buttonClear": "Clear",
284
+ "codeEditor.buttonSave": "Save",
285
+ "codeEditor.previewLabel": "Will execute:",
286
+ "codeEditor.previewTruncated": "…",
287
+ "codeEditor.emptyPlaceholder": "Enter command...",
288
+ "codeEditor.fieldHint": "press enter to open editor",
289
+ "codeEditor.copied": "Copied!",
290
+ "codeEditor.cleared": "Cleared",
279
291
  "board.exampleInterval": "30m",
280
292
  "board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
281
293
  "board.exampleDescription": "translate missing strings",
@@ -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,245 @@
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+Y → copy (NOT redo)
73
+ if (key.ctrl && input === "y") {
74
+ copyToClipboard(value);
75
+ setFlashMsg(t("codeEditor.copied"));
76
+ return;
77
+ }
78
+ // Ctrl+V → paste from clipboard
79
+ if (key.ctrl && input === "v") {
80
+ const clip = readFromClipboard();
81
+ if (clip) {
82
+ const pasted = sanitizePaste(clip);
83
+ if (pasted) {
84
+ const next = [...lines];
85
+ const line = next[cursorRow] ?? "";
86
+ next[cursorRow] =
87
+ line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
88
+ applyMutation(next, cursorRow, cursorCol + pasted.length);
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ // Shift+C → copy, 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") {
114
+ setValue("");
115
+ setCursorRow(0);
116
+ setCursorCol(0);
117
+ setFlashMsg(t("codeEditor.cleared"));
118
+ return;
119
+ }
120
+ // Enter → insert newline at cursor
121
+ if (key.return) {
122
+ const next = [...lines];
123
+ const line = next[cursorRow] ?? "";
124
+ next.splice(cursorRow, 1, line.slice(0, cursorCol), line.slice(cursorCol));
125
+ applyMutation(next, cursorRow + 1, 0);
126
+ return;
127
+ }
128
+ // Arrow keys
129
+ if (key.upArrow) {
130
+ if (cursorRow > 0) {
131
+ const newRow = cursorRow - 1;
132
+ setCursorRow(newRow);
133
+ setCursorCol((c) => Math.min(c, (lines[newRow] ?? "").length));
134
+ }
135
+ return;
136
+ }
137
+ if (key.downArrow) {
138
+ if (cursorRow < lines.length - 1) {
139
+ const newRow = cursorRow + 1;
140
+ setCursorRow(newRow);
141
+ setCursorCol((c) => Math.min(c, (lines[newRow] ?? "").length));
142
+ }
143
+ return;
144
+ }
145
+ if (key.leftArrow) {
146
+ if (cursorCol > 0) {
147
+ setCursorCol((c) => c - 1);
148
+ }
149
+ else if (cursorRow > 0) {
150
+ setCursorRow((r) => r - 1);
151
+ setCursorCol((lines[cursorRow - 1] ?? "").length);
152
+ }
153
+ return;
154
+ }
155
+ if (key.rightArrow) {
156
+ const curLen = (lines[cursorRow] ?? "").length;
157
+ if (cursorCol < curLen) {
158
+ setCursorCol((c) => c + 1);
159
+ }
160
+ else if (cursorRow < lines.length - 1) {
161
+ setCursorRow((r) => r + 1);
162
+ setCursorCol(0);
163
+ }
164
+ return;
165
+ }
166
+ // Backspace / Delete
167
+ if (key.backspace || key.delete) {
168
+ if (cursorCol > 0) {
169
+ const next = [...lines];
170
+ const line = next[cursorRow] ?? "";
171
+ next[cursorRow] =
172
+ line.slice(0, cursorCol - 1) + line.slice(cursorCol);
173
+ applyMutation(next, cursorRow, cursorCol - 1);
174
+ }
175
+ else if (cursorRow > 0) {
176
+ const next = [...lines];
177
+ const prev = next[cursorRow - 1] ?? "";
178
+ const cur = next[cursorRow] ?? "";
179
+ next.splice(cursorRow - 1, 2, prev + cur);
180
+ applyMutation(next, cursorRow - 1, prev.length);
181
+ }
182
+ return;
183
+ }
184
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
185
+ if (input.includes("\x1b[200~")) {
186
+ const pasted = sanitizePaste(input);
187
+ if (pasted) {
188
+ const next = [...lines];
189
+ const line = next[cursorRow] ?? "";
190
+ next[cursorRow] =
191
+ line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
192
+ applyMutation(next, cursorRow, cursorCol + pasted.length);
193
+ }
194
+ return;
195
+ }
196
+ // Multi-char containing CR/LF with no bracketed markers — ignore
197
+ if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
198
+ return;
199
+ // Multi-char printable input = unbracketed single-line paste
200
+ if (input.length > 1 && !key.meta) {
201
+ const pasted = sanitizePaste(input);
202
+ if (pasted) {
203
+ const next = [...lines];
204
+ const line = next[cursorRow] ?? "";
205
+ next[cursorRow] =
206
+ line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
207
+ applyMutation(next, cursorRow, cursorCol + pasted.length);
208
+ }
209
+ return;
210
+ }
211
+ // Single printable char → insert at cursor
212
+ if (input.length === 1 && input >= " " && input <= "~") {
213
+ const next = [...lines];
214
+ const line = next[cursorRow] ?? "";
215
+ next[cursorRow] =
216
+ line.slice(0, cursorCol) + input + line.slice(cursorCol);
217
+ applyMutation(next, cursorRow, cursorCol + 1);
218
+ }
219
+ }, { isActive: true });
220
+ // ---- Live preview footer ----
221
+ const joined = joinCommandLines(value);
222
+ const innerWidth = 58;
223
+ const truncatedPreview = joined.length > innerWidth
224
+ ? joined.slice(0, innerWidth - 1) + t("codeEditor.previewTruncated")
225
+ : joined;
226
+ const accent = theme.accent.brand;
227
+ 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) => {
228
+ const rowIdx = scrollStart + visIdx;
229
+ const isCursor = rowIdx === cursorRow;
230
+ const lineNum = String(rowIdx + 1).padStart(lineNumWidth, " ");
231
+ if (isCursor) {
232
+ const before = line.slice(0, cursorCol);
233
+ const cur = cursorCol < line.length ? line[cursorCol] : " ";
234
+ const after = cursorCol < line.length ? line.slice(cursorCol + 1) : "";
235
+ // Cursor line: render with cursor (no syntax highlight on cursor line)
236
+ 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));
237
+ }
238
+ // Non-cursor line: syntax highlighted (whitespace preserved)
239
+ const segments = highlightSegments(line, CODE_EDITOR_SYNTAX_COLORS, theme.text.muted);
240
+ if (segments.length === 0) {
241
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.text.muted, children: [lineNum, " "] }), _jsx(Text, { children: " " })] }, rowIdx));
242
+ }
243
+ 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") }) })] }) }));
245
+ }
@@ -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 { InlineCommandEditor } from "./InlineCommandEditor.js";
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
- // No onActivate inline editor, owned directly by renderCustom
78
- renderCustom: ({ isActive, onChange }) => (_jsx(InlineCommandEditor, { value: commandValue, hint: t("wizard.commandHint"), isActive: isActive, onChange: (v) => {
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.split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
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
  }
@@ -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 { InlineCommandEditor } from "./InlineCommandEditor.js";
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
- renderCustom: ({ isActive, onChange }) => (_jsx(InlineCommandEditor, { value: commandValue, hint: t("wizard.commandHint"), isActive: isActive, onChange: (v) => {
64
- setCommandValue(v);
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",
3
+ "version": "2.0.4",
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
- }