loop-task 2.0.2 → 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.
- package/dist/config/constants.js +10 -0
- package/dist/i18n/en.json +12 -0
- package/dist/loop-config.js +59 -0
- package/dist/shared/useUndoRedo.js +79 -0
- package/dist/tui/components/CodeEditorModal.js +245 -0
- package/dist/tui/components/CodeEditorPreview.js +14 -0
- package/dist/tui/components/CreateForm.js +12 -10
- package/dist/tui/components/TaskForm.js +12 -12
- package/dist/tui/utils/syntax.js +128 -0
- package/package.json +2 -2
- package/dist/board/App.js +0 -405
- package/dist/board/board-log.js +0 -18
- package/dist/board/components/ActionButtons.js +0 -46
- package/dist/board/components/BoardButton.js +0 -58
- package/dist/board/components/ConfirmModal.js +0 -36
- package/dist/board/components/ContextHelpModal.js +0 -22
- package/dist/board/components/CreateForm.js +0 -271
- package/dist/board/components/CreateProjectModal.js +0 -97
- package/dist/board/components/DeleteProjectConfirm.js +0 -53
- package/dist/board/components/DetailView.js +0 -25
- package/dist/board/components/EditProjectModal.js +0 -99
- package/dist/board/components/FilterBar.js +0 -17
- package/dist/board/components/Footer.js +0 -52
- package/dist/board/components/Header.js +0 -46
- package/dist/board/components/HelpModal.js +0 -65
- package/dist/board/components/Inspector.js +0 -12
- package/dist/board/components/LogModal.js +0 -82
- package/dist/board/components/Navigator.js +0 -84
- package/dist/board/components/ProjectsModal.js +0 -70
- package/dist/board/components/ProjectsPage.js +0 -134
- package/dist/board/components/RunHistory.js +0 -83
- package/dist/board/components/SearchBox.js +0 -10
- package/dist/board/components/SearchSelect.js +0 -132
- package/dist/board/components/TaskBrowser.js +0 -95
- package/dist/board/components/TaskFilterBar.js +0 -6
- package/dist/board/components/TaskForm.js +0 -155
- package/dist/board/components/Timeline.js +0 -5
- package/dist/board/daemon.js +0 -117
- package/dist/board/focus-context.js +0 -98
- package/dist/board/hooks/useBoardKeybindings.js +0 -344
- package/dist/board/hooks/useBreakpoint.js +0 -6
- package/dist/board/hooks/useHoverState.js +0 -11
- package/dist/board/hooks/useInputShortcuts.js +0 -61
- package/dist/board/hooks/useLogStream.js +0 -32
- package/dist/board/hooks/useLoopPolling.js +0 -28
- package/dist/board/hooks/useTabNav.js +0 -60
- package/dist/board/hooks/useTaskKeybindings.js +0 -120
- package/dist/board/index.js +0 -32
- package/dist/board/router.js +0 -16
- package/dist/board/state.js +0 -99
- package/dist/board/toast.js +0 -65
- package/dist/board/types.js +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client/commands.d.ts +0 -11
- package/dist/client/commands.d.ts.map +0 -1
- package/dist/client/commands.js.map +0 -1
- package/dist/client/ipc.d.ts +0 -5
- package/dist/client/ipc.d.ts.map +0 -1
- package/dist/client/ipc.js.map +0 -1
- package/dist/daemon/index.d.ts +0 -2
- package/dist/daemon/index.d.ts.map +0 -1
- package/dist/daemon/index.js.map +0 -1
- package/dist/daemon/manager.d.ts +0 -17
- package/dist/daemon/manager.d.ts.map +0 -1
- package/dist/daemon/manager.js.map +0 -1
- package/dist/daemon/server.d.ts +0 -15
- package/dist/daemon/server.d.ts.map +0 -1
- package/dist/daemon/server.js.map +0 -1
- package/dist/daemon/spawner.d.ts +0 -3
- package/dist/daemon/spawner.d.ts.map +0 -1
- package/dist/daemon/spawner.js.map +0 -1
- package/dist/daemon/state.d.ts +0 -15
- package/dist/daemon/state.d.ts.map +0 -1
- package/dist/daemon/state.js.map +0 -1
- package/dist/duration.d.ts +0 -3
- package/dist/duration.d.ts.map +0 -1
- package/dist/duration.js.map +0 -1
- package/dist/logger.d.ts +0 -11
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/loop-config.d.ts +0 -13
- package/dist/loop-config.d.ts.map +0 -1
- package/dist/loop-config.js.map +0 -1
- package/dist/loop.d.ts +0 -46
- package/dist/loop.d.ts.map +0 -1
- package/dist/loop.js +0 -363
- package/dist/loop.js.map +0 -1
- package/dist/open-board.d.ts +0 -2
- package/dist/open-board.d.ts.map +0 -1
- package/dist/open-board.js +0 -19
- package/dist/open-board.js.map +0 -1
- package/dist/tui/app.d.ts +0 -57
- package/dist/tui/app.d.ts.map +0 -1
- package/dist/tui/app.js.map +0 -1
- package/dist/tui/components/ActionButtons.js +0 -49
- package/dist/tui/components/CommandBuilderField.js +0 -143
- package/dist/tui/components/CommandEditorModal.js +0 -159
- package/dist/tui/components/ConfirmModal.js +0 -9
- package/dist/tui/components/FilterBar.js +0 -13
- package/dist/tui/components/FocusableSearchSelect.js +0 -82
- package/dist/tui/components/Footer.js +0 -42
- package/dist/tui/components/InlineCommandEditor.js +0 -143
- package/dist/tui/components/PatchEditForm.js +0 -78
- package/dist/tui/components/SearchSelect.js +0 -67
- package/dist/tui/components/TaskFilterBar.js +0 -9
- package/dist/tui/create-loop.d.ts +0 -3
- package/dist/tui/create-loop.d.ts.map +0 -1
- package/dist/tui/create-loop.js +0 -90
- package/dist/tui/create-loop.js.map +0 -1
- package/dist/tui/dashboard.d.ts +0 -2
- package/dist/tui/dashboard.d.ts.map +0 -1
- package/dist/tui/dashboard.js +0 -45
- package/dist/tui/dashboard.js.map +0 -1
- package/dist/tui/events.d.ts +0 -3
- package/dist/tui/events.d.ts.map +0 -1
- package/dist/tui/events.js +0 -244
- package/dist/tui/events.js.map +0 -1
- package/dist/tui/popup.d.ts +0 -3
- package/dist/tui/popup.d.ts.map +0 -1
- package/dist/tui/popup.js +0 -58
- package/dist/tui/popup.js.map +0 -1
- package/dist/tui/render.d.ts +0 -9
- package/dist/tui/render.d.ts.map +0 -1
- package/dist/tui/render.js +0 -305
- package/dist/tui/render.js.map +0 -1
- package/dist/types.d.ts +0 -94
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- /package/dist/tui/{app.js → App.js} +0 -0
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
|
+
};
|
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",
|
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,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 {
|
|
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
|
}
|
|
@@ -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
|
}
|