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.
Files changed (131) hide show
  1. package/dist/config/constants.js +10 -0
  2. package/dist/i18n/en.json +12 -0
  3. package/dist/loop-config.js +59 -0
  4. package/dist/shared/useUndoRedo.js +79 -0
  5. package/dist/tui/components/CodeEditorModal.js +245 -0
  6. package/dist/tui/components/CodeEditorPreview.js +14 -0
  7. package/dist/tui/components/CreateForm.js +12 -10
  8. package/dist/tui/components/TaskForm.js +12 -12
  9. package/dist/tui/utils/syntax.js +128 -0
  10. package/package.json +2 -2
  11. package/dist/board/App.js +0 -405
  12. package/dist/board/board-log.js +0 -18
  13. package/dist/board/components/ActionButtons.js +0 -46
  14. package/dist/board/components/BoardButton.js +0 -58
  15. package/dist/board/components/ConfirmModal.js +0 -36
  16. package/dist/board/components/ContextHelpModal.js +0 -22
  17. package/dist/board/components/CreateForm.js +0 -271
  18. package/dist/board/components/CreateProjectModal.js +0 -97
  19. package/dist/board/components/DeleteProjectConfirm.js +0 -53
  20. package/dist/board/components/DetailView.js +0 -25
  21. package/dist/board/components/EditProjectModal.js +0 -99
  22. package/dist/board/components/FilterBar.js +0 -17
  23. package/dist/board/components/Footer.js +0 -52
  24. package/dist/board/components/Header.js +0 -46
  25. package/dist/board/components/HelpModal.js +0 -65
  26. package/dist/board/components/Inspector.js +0 -12
  27. package/dist/board/components/LogModal.js +0 -82
  28. package/dist/board/components/Navigator.js +0 -84
  29. package/dist/board/components/ProjectsModal.js +0 -70
  30. package/dist/board/components/ProjectsPage.js +0 -134
  31. package/dist/board/components/RunHistory.js +0 -83
  32. package/dist/board/components/SearchBox.js +0 -10
  33. package/dist/board/components/SearchSelect.js +0 -132
  34. package/dist/board/components/TaskBrowser.js +0 -95
  35. package/dist/board/components/TaskFilterBar.js +0 -6
  36. package/dist/board/components/TaskForm.js +0 -155
  37. package/dist/board/components/Timeline.js +0 -5
  38. package/dist/board/daemon.js +0 -117
  39. package/dist/board/focus-context.js +0 -98
  40. package/dist/board/hooks/useBoardKeybindings.js +0 -344
  41. package/dist/board/hooks/useBreakpoint.js +0 -6
  42. package/dist/board/hooks/useHoverState.js +0 -11
  43. package/dist/board/hooks/useInputShortcuts.js +0 -61
  44. package/dist/board/hooks/useLogStream.js +0 -32
  45. package/dist/board/hooks/useLoopPolling.js +0 -28
  46. package/dist/board/hooks/useTabNav.js +0 -60
  47. package/dist/board/hooks/useTaskKeybindings.js +0 -120
  48. package/dist/board/index.js +0 -32
  49. package/dist/board/router.js +0 -16
  50. package/dist/board/state.js +0 -99
  51. package/dist/board/toast.js +0 -65
  52. package/dist/board/types.js +0 -1
  53. package/dist/cli.d.ts +0 -3
  54. package/dist/cli.d.ts.map +0 -1
  55. package/dist/cli.js.map +0 -1
  56. package/dist/client/commands.d.ts +0 -11
  57. package/dist/client/commands.d.ts.map +0 -1
  58. package/dist/client/commands.js.map +0 -1
  59. package/dist/client/ipc.d.ts +0 -5
  60. package/dist/client/ipc.d.ts.map +0 -1
  61. package/dist/client/ipc.js.map +0 -1
  62. package/dist/daemon/index.d.ts +0 -2
  63. package/dist/daemon/index.d.ts.map +0 -1
  64. package/dist/daemon/index.js.map +0 -1
  65. package/dist/daemon/manager.d.ts +0 -17
  66. package/dist/daemon/manager.d.ts.map +0 -1
  67. package/dist/daemon/manager.js.map +0 -1
  68. package/dist/daemon/server.d.ts +0 -15
  69. package/dist/daemon/server.d.ts.map +0 -1
  70. package/dist/daemon/server.js.map +0 -1
  71. package/dist/daemon/spawner.d.ts +0 -3
  72. package/dist/daemon/spawner.d.ts.map +0 -1
  73. package/dist/daemon/spawner.js.map +0 -1
  74. package/dist/daemon/state.d.ts +0 -15
  75. package/dist/daemon/state.d.ts.map +0 -1
  76. package/dist/daemon/state.js.map +0 -1
  77. package/dist/duration.d.ts +0 -3
  78. package/dist/duration.d.ts.map +0 -1
  79. package/dist/duration.js.map +0 -1
  80. package/dist/logger.d.ts +0 -11
  81. package/dist/logger.d.ts.map +0 -1
  82. package/dist/logger.js.map +0 -1
  83. package/dist/loop-config.d.ts +0 -13
  84. package/dist/loop-config.d.ts.map +0 -1
  85. package/dist/loop-config.js.map +0 -1
  86. package/dist/loop.d.ts +0 -46
  87. package/dist/loop.d.ts.map +0 -1
  88. package/dist/loop.js +0 -363
  89. package/dist/loop.js.map +0 -1
  90. package/dist/open-board.d.ts +0 -2
  91. package/dist/open-board.d.ts.map +0 -1
  92. package/dist/open-board.js +0 -19
  93. package/dist/open-board.js.map +0 -1
  94. package/dist/tui/app.d.ts +0 -57
  95. package/dist/tui/app.d.ts.map +0 -1
  96. package/dist/tui/app.js.map +0 -1
  97. package/dist/tui/components/ActionButtons.js +0 -49
  98. package/dist/tui/components/CommandBuilderField.js +0 -143
  99. package/dist/tui/components/CommandEditorModal.js +0 -159
  100. package/dist/tui/components/ConfirmModal.js +0 -9
  101. package/dist/tui/components/FilterBar.js +0 -13
  102. package/dist/tui/components/FocusableSearchSelect.js +0 -82
  103. package/dist/tui/components/Footer.js +0 -42
  104. package/dist/tui/components/InlineCommandEditor.js +0 -143
  105. package/dist/tui/components/PatchEditForm.js +0 -78
  106. package/dist/tui/components/SearchSelect.js +0 -67
  107. package/dist/tui/components/TaskFilterBar.js +0 -9
  108. package/dist/tui/create-loop.d.ts +0 -3
  109. package/dist/tui/create-loop.d.ts.map +0 -1
  110. package/dist/tui/create-loop.js +0 -90
  111. package/dist/tui/create-loop.js.map +0 -1
  112. package/dist/tui/dashboard.d.ts +0 -2
  113. package/dist/tui/dashboard.d.ts.map +0 -1
  114. package/dist/tui/dashboard.js +0 -45
  115. package/dist/tui/dashboard.js.map +0 -1
  116. package/dist/tui/events.d.ts +0 -3
  117. package/dist/tui/events.d.ts.map +0 -1
  118. package/dist/tui/events.js +0 -244
  119. package/dist/tui/events.js.map +0 -1
  120. package/dist/tui/popup.d.ts +0 -3
  121. package/dist/tui/popup.d.ts.map +0 -1
  122. package/dist/tui/popup.js +0 -58
  123. package/dist/tui/popup.js.map +0 -1
  124. package/dist/tui/render.d.ts +0 -9
  125. package/dist/tui/render.d.ts.map +0 -1
  126. package/dist/tui/render.js +0 -305
  127. package/dist/tui/render.js.map +0 -1
  128. package/dist/types.d.ts +0 -94
  129. package/dist/types.d.ts.map +0 -1
  130. package/dist/types.js.map +0 -1
  131. /package/dist/tui/{app.js → App.js} +0 -0
@@ -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
  }