loop-task 2.0.15 → 2.1.0
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/cli/import-validator.js +1 -1
- package/dist/cli/import-writer.js +5 -1
- package/dist/cli.js +2 -1
- package/dist/config/constants.js +2 -12
- package/dist/core/loop-controller.js +29 -4
- package/dist/daemon/http-server.js +0 -6
- package/dist/hooks/useLoopFormValidation.js +0 -9
- package/dist/i18n/en.json +9 -1
- package/dist/shared/clipboard.js +80 -10
- package/dist/tui/App.js +0 -15
- package/dist/tui/commands.js +0 -4
- package/dist/tui/components/CodeEditorModal.js +49 -34
- package/dist/tui/components/CommandInput.js +0 -8
- package/dist/tui/components/CreateForm.js +0 -1
- package/dist/tui/components/ExportModal.js +6 -0
- package/dist/tui/components/LogModal.js +7 -0
- package/dist/tui/components/TaskForm.js +44 -12
- package/dist/tui/daemon.js +1 -1
- package/dist/tui/utils/paste.js +11 -5
- package/package.json +1 -1
|
@@ -38,7 +38,7 @@ const LOOP_FIELDS = [
|
|
|
38
38
|
{ name: "remainingDelayMs", type: "number", nullable: true },
|
|
39
39
|
{ name: "pid", type: "number", nullable: false },
|
|
40
40
|
{ name: "maxRunsReached", type: "boolean", nullable: false },
|
|
41
|
-
{ name: "runHistory", type: "array", nullable:
|
|
41
|
+
{ name: "runHistory", type: "array", nullable: true },
|
|
42
42
|
{ name: "skippedCount", type: "number", nullable: false },
|
|
43
43
|
{ name: "projectId", type: "string", nullable: false },
|
|
44
44
|
{ name: "offset", type: "number", nullable: true },
|
|
@@ -9,8 +9,12 @@ function backupFilePath(filePath) {
|
|
|
9
9
|
export function atomicImportWrite(loops, tasks, projects) {
|
|
10
10
|
const dataDir = getDataDir();
|
|
11
11
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
12
|
+
const normalizedLoops = loops.map((loop) => ({
|
|
13
|
+
...loop,
|
|
14
|
+
runHistory: Array.isArray(loop.runHistory) ? loop.runHistory : [],
|
|
15
|
+
}));
|
|
12
16
|
const contents = {
|
|
13
|
-
"loops.json": JSON.stringify(
|
|
17
|
+
"loops.json": JSON.stringify(normalizedLoops, null, 2),
|
|
14
18
|
"tasks.json": JSON.stringify(tasks, null, 2),
|
|
15
19
|
"projects.json": JSON.stringify(projects, null, 2),
|
|
16
20
|
};
|
package/dist/cli.js
CHANGED
|
@@ -206,10 +206,11 @@ program
|
|
|
206
206
|
sendRequest({ type: "task-list" }),
|
|
207
207
|
sendRequest({ type: "project-list" }),
|
|
208
208
|
]);
|
|
209
|
+
const loops = loopsRes.type === "ok" ? loopsRes.data : [];
|
|
209
210
|
const exportData = {
|
|
210
211
|
version: 2,
|
|
211
212
|
exportedAt: new Date().toISOString(),
|
|
212
|
-
loops:
|
|
213
|
+
loops: loops.map(({ runHistory: _ignored, ...rest }) => rest),
|
|
213
214
|
tasks: tasksRes.type === "ok" ? tasksRes.data : [],
|
|
214
215
|
projects: projectsRes.type === "ok" ? projectsRes.data : [],
|
|
215
216
|
};
|
package/dist/config/constants.js
CHANGED
|
@@ -13,7 +13,6 @@ export const LOG_TAIL_DEFAULT = 50;
|
|
|
13
13
|
export const BOARD_BREAKPOINT_WIDTH = 80;
|
|
14
14
|
export const HEADER_COMPACT_WIDTH = 60;
|
|
15
15
|
export const SEARCH_SELECT_HEIGHT = 6;
|
|
16
|
-
// ── Clipboard / bracketed paste ─────────────────────────────────────
|
|
17
16
|
// DECSET 2004: terminal wraps pasted text in ESC[200~ ... ESC[201~ so the
|
|
18
17
|
// app can insert it wholesale instead of processing each char as a keypress.
|
|
19
18
|
export const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
|
|
@@ -35,30 +34,24 @@ export const ENTITY_COLORS = {
|
|
|
35
34
|
task: "#a78bfa", // purple
|
|
36
35
|
project: "#34d399", // green
|
|
37
36
|
};
|
|
38
|
-
// ── Wizard step counts ──────────────────────────────────────────────
|
|
39
37
|
export const WIZARD_LOOP_REQUIRED_STEPS = 3;
|
|
40
38
|
export const WIZARD_LOOP_TOTAL_STEPS = 7;
|
|
41
39
|
export const WIZARD_TASK_REQUIRED_STEPS = 2;
|
|
42
40
|
export const WIZARD_TASK_TOTAL_STEPS = 4;
|
|
43
41
|
export const WIZARD_PROJECT_REQUIRED_STEPS = 1;
|
|
44
42
|
export const WIZARD_PROJECT_TOTAL_STEPS = 2;
|
|
45
|
-
// ── Command tiers ───────────────────────────────────────────────────
|
|
46
43
|
export const COMMAND_TIER_ACTION = "action";
|
|
47
44
|
export const COMMAND_TIER_CONFIRM = "confirm";
|
|
48
45
|
export const COMMAND_TIER_GLOBAL = "global";
|
|
49
|
-
// ── Command categories ─────────────────────────────────────────────
|
|
50
46
|
export const COMMAND_CATEGORY_GLOBAL = "global";
|
|
51
47
|
export const COMMAND_CATEGORY_FILTERS = "filters";
|
|
52
48
|
export const COMMAND_CATEGORY_LOOP = "loop";
|
|
53
49
|
export const COMMAND_CATEGORY_TASK = "task";
|
|
54
50
|
export const COMMAND_CATEGORY_PROJECT = "project";
|
|
55
|
-
// ── Confirm keywords ────────────────────────────────────────────────
|
|
56
51
|
export const CONFIRM_YES = "yes";
|
|
57
52
|
export const CONFIRM_CANCEL = "cancel";
|
|
58
|
-
// ── Panel focus types ───────────────────────────────────────────────
|
|
59
53
|
export const PANEL_LEFT = "left";
|
|
60
54
|
export const PANEL_RIGHT = "right";
|
|
61
|
-
// ── Command builder templates ──────────────────────────────────────
|
|
62
55
|
export const COMMAND_TEMPLATES = [
|
|
63
56
|
{ label: "npm run", command: "npm", args: "run" },
|
|
64
57
|
{ label: "npm test", command: "npm", args: "test" },
|
|
@@ -71,19 +64,16 @@ export const COMMAND_TEMPLATES = [
|
|
|
71
64
|
{ label: "make", command: "make", args: "" },
|
|
72
65
|
{ label: "shell script", command: "bash", args: "./script.sh" },
|
|
73
66
|
];
|
|
74
|
-
// ── Command input constants ─────────────────────────────────────────
|
|
75
67
|
export const COMMAND_INPUT_HEIGHT = 6;
|
|
76
68
|
export const COMMAND_INPUT_DROPDOWN_MAX_VISIBLE = 6;
|
|
77
|
-
// ── HTTP API server ─────────────────────────────────────────────────
|
|
78
69
|
export const HTTP_API_PORT = 8845;
|
|
79
70
|
export const HTTP_API_HOST = "127.0.0.1";
|
|
80
|
-
// ── Ctrl shortcut hints (for display) ───────────────────────────────
|
|
81
71
|
export const EXPORT_MAX_PREVIEW_LINES = 200;
|
|
82
72
|
export const CTRL_SHORTCUT_EDIT = "Ctrl+E";
|
|
83
73
|
export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
|
|
84
|
-
// ── Code editor constants ──────────────────────────────────────────
|
|
85
74
|
export const CODE_EDITOR_MAX_VISIBLE = 2;
|
|
86
|
-
export const CODE_EDITOR_MODAL_HEIGHT =
|
|
75
|
+
export const CODE_EDITOR_MODAL_HEIGHT = 40;
|
|
76
|
+
export const CODE_EDITOR_MODAL_WIDTH = 120;
|
|
87
77
|
export const CODE_EDITOR_UNDO_LIMIT = 50;
|
|
88
78
|
export const CODE_EDITOR_SYNTAX_COLORS = {
|
|
89
79
|
flag: "#38bdf8",
|
|
@@ -330,14 +330,39 @@ export class LoopController extends EventEmitter {
|
|
|
330
330
|
});
|
|
331
331
|
this.runAbortController = new AbortController();
|
|
332
332
|
const task = this.options.taskId ? this.taskResolver(this.options.taskId) : null;
|
|
333
|
-
const command = task?.command ?? this.options.command;
|
|
334
|
-
const commandArgs = task?.commandArgs ?? this.options.commandArgs;
|
|
335
333
|
const cwd = resolveEffectiveCwd(this.options.cwd, this.projectDirectory);
|
|
336
334
|
const chainContext = {};
|
|
337
335
|
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
338
|
-
|
|
336
|
+
// Determine the command(s) to run
|
|
337
|
+
const taskCommands = task?.commands?.length
|
|
338
|
+
? task.commands
|
|
339
|
+
: [{
|
|
340
|
+
command: task?.command ?? this.options.command,
|
|
341
|
+
commandArgs: task?.commandArgs ?? this.options.commandArgs,
|
|
342
|
+
commandRaw: task?.commandRaw ?? this.options.commandRaw,
|
|
343
|
+
}];
|
|
344
|
+
const shouldCaptureStdout = hasChainTasks || taskCommands.length > 1;
|
|
345
|
+
const results = await Promise.allSettled(taskCommands.map((cmd) => executeCommand(cmd.command, cmd.commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount, shouldCaptureStdout)));
|
|
339
346
|
this.runAbortController = null;
|
|
340
|
-
|
|
347
|
+
// Find first failure (if any) — stop-immediately policy
|
|
348
|
+
const firstFailure = results.find((r) => r.status === "rejected");
|
|
349
|
+
const firstNonZero = results.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
|
|
350
|
+
const exitCode = firstFailure ? 1 : (firstNonZero?.value.exitCode ?? 0);
|
|
351
|
+
const totalDuration = results.reduce((sum, r) => {
|
|
352
|
+
if (r.status === "fulfilled")
|
|
353
|
+
return sum + r.value.duration;
|
|
354
|
+
return sum;
|
|
355
|
+
}, 0);
|
|
356
|
+
const combinedStdout = results
|
|
357
|
+
.filter((r) => r.status === "fulfilled" && !!r.value.stdout)
|
|
358
|
+
.map((r) => r.value.stdout)
|
|
359
|
+
.join("\n");
|
|
360
|
+
const result = {
|
|
361
|
+
exitCode,
|
|
362
|
+
duration: totalDuration,
|
|
363
|
+
stdout: combinedStdout || undefined,
|
|
364
|
+
};
|
|
365
|
+
if (shouldCaptureStdout && result.stdout) {
|
|
341
366
|
const parsed = parseStdout(result.stdout);
|
|
342
367
|
if (parsed !== null) {
|
|
343
368
|
Object.assign(chainContext, parsed);
|
|
@@ -139,7 +139,6 @@ export class HttpApiServer {
|
|
|
139
139
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
140
140
|
this.routes.push({ method, segments, handler });
|
|
141
141
|
};
|
|
142
|
-
// ── Loops ──────────────────────────────────────────────────────
|
|
143
142
|
r("GET", "/api/loops", (_req, res) => {
|
|
144
143
|
sendOk(res, this.manager.list());
|
|
145
144
|
});
|
|
@@ -249,7 +248,6 @@ export class HttpApiServer {
|
|
|
249
248
|
const count = this.manager.stopAllLoops();
|
|
250
249
|
sendOk(res, { count });
|
|
251
250
|
});
|
|
252
|
-
// ── Loop logs ─────────────────────────────────────────────────
|
|
253
251
|
r("GET", "/api/loops/:id/logs", (_req, res, params) => {
|
|
254
252
|
const logPath = this.manager.getLogPath(params.id);
|
|
255
253
|
if (!logPath) {
|
|
@@ -351,7 +349,6 @@ export class HttpApiServer {
|
|
|
351
349
|
});
|
|
352
350
|
sendOk(res, parts.join(""));
|
|
353
351
|
});
|
|
354
|
-
// ── Tasks ──────────────────────────────────────────────────────
|
|
355
352
|
r("GET", "/api/tasks", (_req, res) => {
|
|
356
353
|
sendOk(res, this.taskManager.list());
|
|
357
354
|
});
|
|
@@ -402,7 +399,6 @@ export class HttpApiServer {
|
|
|
402
399
|
}
|
|
403
400
|
sendOk(res);
|
|
404
401
|
});
|
|
405
|
-
// ── Projects ───────────────────────────────────────────────────
|
|
406
402
|
r("GET", "/api/projects", (_req, res) => {
|
|
407
403
|
sendOk(res, this.projectManager.getAll());
|
|
408
404
|
});
|
|
@@ -455,7 +451,6 @@ export class HttpApiServer {
|
|
|
455
451
|
}
|
|
456
452
|
}
|
|
457
453
|
});
|
|
458
|
-
// ── Swagger / OpenAPI ────────────────────────────────────────
|
|
459
454
|
r("GET", "/api/openapi.json", (_req, res) => {
|
|
460
455
|
const spec = this.buildOpenApiSpec();
|
|
461
456
|
const body = JSON.stringify(spec, null, 2);
|
|
@@ -481,7 +476,6 @@ export class HttpApiServer {
|
|
|
481
476
|
});
|
|
482
477
|
res.end(html);
|
|
483
478
|
});
|
|
484
|
-
// ── SSE Events ────────────────────────────────────────────────
|
|
485
479
|
r("GET", "/api/events", (_req, res) => {
|
|
486
480
|
res.writeHead(200, {
|
|
487
481
|
"Content-Type": "text/event-stream",
|
|
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|
|
2
2
|
import { parseDuration } from "../duration.js";
|
|
3
3
|
import { parseMaxRuns } from "../loop-config.js";
|
|
4
4
|
import { t } from "../i18n/index.js";
|
|
5
|
-
// ── Types ───────────────────────────────────────────────────────────
|
|
6
5
|
export const createFields = [
|
|
7
6
|
"interval",
|
|
8
7
|
"taskMode",
|
|
@@ -14,7 +13,6 @@ export const createFields = [
|
|
|
14
13
|
"maxRuns",
|
|
15
14
|
"project",
|
|
16
15
|
];
|
|
17
|
-
// ── Validation ──────────────────────────────────────────────────────
|
|
18
16
|
/**
|
|
19
17
|
* Validates a single loop form field and returns an error string, or
|
|
20
18
|
* `null` if the value is valid.
|
|
@@ -30,7 +28,6 @@ export const createFields = [
|
|
|
30
28
|
function validateField(key, values) {
|
|
31
29
|
const value = values[key] ?? "";
|
|
32
30
|
switch (key) {
|
|
33
|
-
// ── interval ──────────────────────────────────────────────────
|
|
34
31
|
// Always required; delegates to parseDuration() which throws on
|
|
35
32
|
// empty, invalid syntax, or non-positive values.
|
|
36
33
|
case "interval": {
|
|
@@ -45,7 +42,6 @@ function validateField(key, values) {
|
|
|
45
42
|
return err instanceof Error ? err.message : String(err);
|
|
46
43
|
}
|
|
47
44
|
}
|
|
48
|
-
// ── command ───────────────────────────────────────────────────
|
|
49
45
|
// Required only when the user chose inline mode.
|
|
50
46
|
case "command": {
|
|
51
47
|
if (values.taskMode === "existing")
|
|
@@ -55,7 +51,6 @@ function validateField(key, values) {
|
|
|
55
51
|
}
|
|
56
52
|
return null;
|
|
57
53
|
}
|
|
58
|
-
// ── cwd ───────────────────────────────────────────────────────
|
|
59
54
|
// Optional; if provided the directory must exist on disk.
|
|
60
55
|
case "cwd": {
|
|
61
56
|
if (!value.trim())
|
|
@@ -65,7 +60,6 @@ function validateField(key, values) {
|
|
|
65
60
|
}
|
|
66
61
|
return null;
|
|
67
62
|
}
|
|
68
|
-
// ── description ───────────────────────────────────────────────
|
|
69
63
|
// Always required.
|
|
70
64
|
case "description": {
|
|
71
65
|
if (!value.trim()) {
|
|
@@ -73,7 +67,6 @@ function validateField(key, values) {
|
|
|
73
67
|
}
|
|
74
68
|
return null;
|
|
75
69
|
}
|
|
76
|
-
// ── maxRuns ───────────────────────────────────────────────────
|
|
77
70
|
// Optional; if provided it must be a positive integer.
|
|
78
71
|
case "maxRuns": {
|
|
79
72
|
if (!value.trim())
|
|
@@ -86,7 +79,6 @@ function validateField(key, values) {
|
|
|
86
79
|
return err instanceof Error ? err.message : String(err);
|
|
87
80
|
}
|
|
88
81
|
}
|
|
89
|
-
// ── no validation needed ──────────────────────────────────────
|
|
90
82
|
case "taskMode":
|
|
91
83
|
case "runNow":
|
|
92
84
|
case "project":
|
|
@@ -116,7 +108,6 @@ function validateAll(values) {
|
|
|
116
108
|
}
|
|
117
109
|
return errors;
|
|
118
110
|
}
|
|
119
|
-
// ── Hook ────────────────────────────────────────────────────────────
|
|
120
111
|
/**
|
|
121
112
|
* Shared form-validation hook for loop creation / edit forms.
|
|
122
113
|
*
|
package/dist/i18n/en.json
CHANGED
|
@@ -279,7 +279,7 @@
|
|
|
279
279
|
"cmdEditor.lines": "lines",
|
|
280
280
|
"cmdEditor.inlineHint": "enter: new line . ctrl+v: paste . ctrl+y: copy . tab: next field . ctrl+s: save",
|
|
281
281
|
"codeEditor.title": "Command Editor",
|
|
282
|
-
"codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo",
|
|
282
|
+
"codeEditor.hint": "ctrl+s save . esc cancel . ctrl+z/ctrl+shift+z undo/redo . use && for parallel commands",
|
|
283
283
|
"codeEditor.buttonCopy": "Copy",
|
|
284
284
|
"codeEditor.buttonPaste": "Paste",
|
|
285
285
|
"codeEditor.buttonClear": "Clear",
|
|
@@ -290,6 +290,14 @@
|
|
|
290
290
|
"codeEditor.fieldHint": "press enter to open editor",
|
|
291
291
|
"codeEditor.copied": "Copied!",
|
|
292
292
|
"codeEditor.cleared": "Cleared",
|
|
293
|
+
"codeEditor.clipboardEmpty": "Clipboard empty — right-click to paste",
|
|
294
|
+
"codeEditor.commandsTitle": "Parallel Commands",
|
|
295
|
+
"codeEditor.commandsHint": "tab: next . shift+tab: prev . ctrl+s: save all . esc: cancel",
|
|
296
|
+
"codeEditor.commandLabel": "Command",
|
|
297
|
+
"codeEditor.commandEmpty": "empty = deleted",
|
|
298
|
+
"codeEditor.commandMax": "max 4",
|
|
299
|
+
"wizard.taskCommandsPrompt": "Commands to run (parallel)",
|
|
300
|
+
"wizard.taskCommandsHint": "Multiple commands run at once, merge JSON output",
|
|
293
301
|
"board.exampleInterval": "30m",
|
|
294
302
|
"board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
|
|
295
303
|
"board.exampleDescription": "translate missing strings",
|
package/dist/shared/clipboard.js
CHANGED
|
@@ -1,39 +1,109 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { platform } from "node:os";
|
|
3
|
+
/**
|
|
4
|
+
* Write text to the system clipboard. Never throws — returns false if no
|
|
5
|
+
* clipboard tool is available (e.g. a headless SSH box without xclip/xsel).
|
|
6
|
+
*
|
|
7
|
+
* Linux toolchain tried in order: xclip → xsel → wl-copy (Wayland) →
|
|
8
|
+
* tmux load-buffer (when $TMUX is set) → OSC 52 (works over SSH because
|
|
9
|
+
* the local terminal owns the clipboard).
|
|
10
|
+
*/
|
|
3
11
|
export function copyToClipboard(text) {
|
|
4
12
|
const os = platform();
|
|
5
13
|
if (os === "win32") {
|
|
6
|
-
|
|
14
|
+
try {
|
|
15
|
+
execFileSync("clip", { input: text });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
7
21
|
}
|
|
8
|
-
|
|
9
|
-
|
|
22
|
+
if (os === "darwin") {
|
|
23
|
+
try {
|
|
24
|
+
execFileSync("pbcopy", { input: text });
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
10
30
|
}
|
|
11
|
-
|
|
31
|
+
// Linux / BSD: try each clipboard tool in order. All calls are guarded —
|
|
32
|
+
// a missing binary must never throw, since callers invoke this from
|
|
33
|
+
// synchronous keypress handlers (an uncaught throw here kills the app).
|
|
34
|
+
const tries = [
|
|
35
|
+
() => execFileSync("xclip", ["-selection", "clipboard"], { input: text }),
|
|
36
|
+
() => execFileSync("xsel", ["--clipboard", "--input"], { input: text }),
|
|
37
|
+
() => execFileSync("wl-copy", [], { input: text }),
|
|
38
|
+
];
|
|
39
|
+
for (const tryFn of tries) {
|
|
12
40
|
try {
|
|
13
|
-
|
|
41
|
+
tryFn();
|
|
42
|
+
return true;
|
|
14
43
|
}
|
|
15
44
|
catch {
|
|
16
|
-
|
|
45
|
+
// try next tool
|
|
17
46
|
}
|
|
18
47
|
}
|
|
48
|
+
if (process.env.TMUX) {
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("tmux", ["load-buffer", "-"], { input: text });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// fall through to OSC 52
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// OSC 52: ask the connected terminal to copy. Works over SSH because the
|
|
58
|
+
// local terminal owns the clipboard. Most modern terminals support it
|
|
59
|
+
// (iTerm2, WezTerm, Windows Terminal, Kitty, Alacritty).
|
|
60
|
+
try {
|
|
61
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
19
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Read text from the system clipboard. Returns "" if unavailable.
|
|
70
|
+
*
|
|
71
|
+
* Linux toolchain: xclip → xsel → wl-paste → tmux save-buffer -. OSC 52
|
|
72
|
+
* reply is not solicited synchronously (timings are unreliable across
|
|
73
|
+
* terminals); callers fall back to bracketed-paste detection for input.
|
|
74
|
+
*/
|
|
20
75
|
export function readFromClipboard() {
|
|
21
76
|
const os = platform();
|
|
22
77
|
try {
|
|
23
78
|
if (os === "win32") {
|
|
24
79
|
return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
|
|
25
80
|
}
|
|
26
|
-
|
|
81
|
+
if (os === "darwin") {
|
|
27
82
|
return execFileSync("pbpaste", { encoding: "utf-8" });
|
|
28
83
|
}
|
|
29
|
-
|
|
84
|
+
// Linux / BSD
|
|
85
|
+
const tries = [
|
|
86
|
+
() => execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" }),
|
|
87
|
+
() => execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" }),
|
|
88
|
+
() => execFileSync("wl-paste", [], { encoding: "utf-8" }),
|
|
89
|
+
];
|
|
90
|
+
for (const tryFn of tries) {
|
|
30
91
|
try {
|
|
31
|
-
return
|
|
92
|
+
return tryFn();
|
|
32
93
|
}
|
|
33
94
|
catch {
|
|
34
|
-
|
|
95
|
+
// try next tool
|
|
35
96
|
}
|
|
36
97
|
}
|
|
98
|
+
if (process.env.TMUX) {
|
|
99
|
+
try {
|
|
100
|
+
return execFileSync("tmux", ["save-buffer", "-"], { encoding: "utf-8" });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
37
107
|
}
|
|
38
108
|
catch {
|
|
39
109
|
return "";
|
package/dist/tui/App.js
CHANGED
|
@@ -42,10 +42,8 @@ export function App(props) {
|
|
|
42
42
|
const { exit } = useApp();
|
|
43
43
|
const { loops, daemonStatus, refresh } = useLoopPolling();
|
|
44
44
|
const { view, push, pop } = useRouter("board");
|
|
45
|
-
// ── Tab and panel state (8.1, 8.2) ──
|
|
46
45
|
const [activeTab, setActiveTab] = useState("loops");
|
|
47
46
|
const [focusedPanel, setFocusedPanel] = useState("left");
|
|
48
|
-
// ── Filter state ──
|
|
49
47
|
const [filters, setFilters] = useState(defaultFilters);
|
|
50
48
|
const [sort, setSort] = useState("description");
|
|
51
49
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -66,19 +64,15 @@ export function App(props) {
|
|
|
66
64
|
const [currentProjectId, setCurrentProjectId] = useState("all");
|
|
67
65
|
const [projectSelectedIndex, setProjectSelectedIndex] = useState(0);
|
|
68
66
|
const [projectFilters, setProjectFilters] = useState(defaultProjectFilters);
|
|
69
|
-
// ── Overlay state ──
|
|
70
67
|
const [commandsBrowserOpen, setCommandsBrowserOpen] = useState(false);
|
|
71
68
|
const [contextHelpOpen, setContextHelpOpen] = useState(false);
|
|
72
69
|
const [exportModal, setExportModal] = useState(null);
|
|
73
|
-
// ── Confirm state (8.3) ──
|
|
74
70
|
const [confirmState, setConfirmState] = useState(null);
|
|
75
|
-
// ── Search state (command-driven search) ──
|
|
76
71
|
const [searchState, setSearchState] = useState(null);
|
|
77
72
|
const [searchValue, setSearchValue] = useState("");
|
|
78
73
|
const [debugMode, setDebugMode] = useState(false);
|
|
79
74
|
const [debugEntries, setDebugEntries] = useState([]);
|
|
80
75
|
const [chordState, setChordState] = useState(null);
|
|
81
|
-
// ── Command bar input state (for InputOwner resolution) ──
|
|
82
76
|
const [commandBarHasText, setCommandBarHasText] = useState(false);
|
|
83
77
|
const [commandBarDropdownOpen, setCommandBarDropdownOpen] = useState(false);
|
|
84
78
|
const { toasts, push: pushToast } = useToasts();
|
|
@@ -190,7 +184,6 @@ export function App(props) {
|
|
|
190
184
|
if (handler)
|
|
191
185
|
handler();
|
|
192
186
|
}, [activeTab, selected, selectedTask, selectedProjectEntity, pushToast]);
|
|
193
|
-
// ── Command handler — dictionary dispatch, no switch/case ──
|
|
194
187
|
const commandHandlers = {
|
|
195
188
|
edit: () => {
|
|
196
189
|
if (activeTab === "loops" && selected) {
|
|
@@ -332,7 +325,6 @@ export function App(props) {
|
|
|
332
325
|
pushToast("error", `Unknown command: ${value}`);
|
|
333
326
|
}
|
|
334
327
|
}
|
|
335
|
-
// ── Confirm handlers (8.3) ──
|
|
336
328
|
const handleConfirmYes = useCallback(() => {
|
|
337
329
|
if (confirmState) {
|
|
338
330
|
confirmState.onConfirm();
|
|
@@ -368,11 +360,9 @@ export function App(props) {
|
|
|
368
360
|
pop();
|
|
369
361
|
pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
|
|
370
362
|
};
|
|
371
|
-
// ── Resolve query for LeftPanel based on activeTab + search state ──
|
|
372
363
|
const leftPanelQuery = searchState?.active
|
|
373
364
|
? searchValue
|
|
374
365
|
: activeTab === "tasks" ? taskQuery : filters.query;
|
|
375
|
-
// ── Search handlers ──
|
|
376
366
|
const handleSearchChange = useCallback((value) => {
|
|
377
367
|
setSearchValue(value);
|
|
378
368
|
if (activeTab === "tasks") {
|
|
@@ -395,7 +385,6 @@ export function App(props) {
|
|
|
395
385
|
}
|
|
396
386
|
setSearchState(null);
|
|
397
387
|
}, [activeTab]);
|
|
398
|
-
// ── Command context (8.3) ──
|
|
399
388
|
const commandContext = useMemo(() => ({ activeTab, selectedLoop: selected, selectedTask, selectedProject: selectedProjectEntity }), [activeTab, selected, selectedTask]);
|
|
400
389
|
// Contextual action for the focused panel: shared by Ctrl+Enter and, for
|
|
401
390
|
// terminals that collapse Ctrl+Enter to plain Enter, by Enter on an empty
|
|
@@ -433,7 +422,6 @@ export function App(props) {
|
|
|
433
422
|
const handlerKey = activeTab !== "loops" ? `${activeTab}:` : `loops:${focusedPanel}`;
|
|
434
423
|
handlers[handlerKey]?.();
|
|
435
424
|
};
|
|
436
|
-
// ── Pop topmost overlay layer (Escape handler helper) ──
|
|
437
425
|
const popLayer = () => {
|
|
438
426
|
if (confirmState) {
|
|
439
427
|
setConfirmState(null);
|
|
@@ -472,7 +460,6 @@ export function App(props) {
|
|
|
472
460
|
});
|
|
473
461
|
return true;
|
|
474
462
|
};
|
|
475
|
-
// ── Global useInput (8.2) ──
|
|
476
463
|
useInput((input, key) => {
|
|
477
464
|
// Ctrl+C always quits if no modal open
|
|
478
465
|
if (key.ctrl && input === "c") {
|
|
@@ -579,7 +566,6 @@ export function App(props) {
|
|
|
579
566
|
// Search mode is handled by CommandInput component, not here (non-Escape only)
|
|
580
567
|
if (searchState?.active && !key.escape)
|
|
581
568
|
return;
|
|
582
|
-
// ── Escape: pop the topmost overlay layer (2.2) ──
|
|
583
569
|
if (key.escape) {
|
|
584
570
|
popLayer();
|
|
585
571
|
return;
|
|
@@ -642,7 +628,6 @@ export function App(props) {
|
|
|
642
628
|
// Modals disable panel input behind them; CommandInput stays active for confirm/search
|
|
643
629
|
const anyModalOpen = !!(logModalRun || commandsBrowserOpen || exportModal);
|
|
644
630
|
const commandInputDisabled = anyModalOpen;
|
|
645
|
-
// ── Resolved input owner (1.2) ──
|
|
646
631
|
const inputOwner = useMemo(() => resolveInputOwner({
|
|
647
632
|
modalOpen: !!(logModalRun || commandsBrowserOpen || exportModal || contextHelpOpen || confirmState || searchState?.active),
|
|
648
633
|
commandBarHasText,
|
package/dist/tui/commands.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { COMMAND_TIER_ACTION, COMMAND_TIER_CONFIRM, COMMAND_TIER_GLOBAL, COMMAND_CATEGORY_GLOBAL, COMMAND_CATEGORY_FILTERS, COMMAND_CATEGORY_LOOP, COMMAND_CATEGORY_TASK, COMMAND_CATEGORY_PROJECT, } from '../config/constants.js';
|
|
2
2
|
import { t } from '../i18n/index.js';
|
|
3
|
-
// ── Shared helpers ───────────────────────────────────────────────────
|
|
4
3
|
function globalCommands() {
|
|
5
4
|
return [
|
|
6
5
|
{ label: t('cmd.help'), value: 'help', hint: '', tier: COMMAND_TIER_GLOBAL, category: COMMAND_CATEGORY_GLOBAL, shortcut: 'ctrl+p' },
|
|
@@ -29,7 +28,6 @@ function projectFilterCommands() {
|
|
|
29
28
|
{ label: t('cmd.searchProjects'), value: 'search', hint: '', tier: COMMAND_TIER_ACTION, category: COMMAND_CATEGORY_FILTERS, shortcut: 'ctrl+f+s' },
|
|
30
29
|
];
|
|
31
30
|
}
|
|
32
|
-
// ── Command ranking (exact > prefix > fuzzy) ─────────────────────────
|
|
33
31
|
/**
|
|
34
32
|
* Re-rank autocomplete options: exact match → prefix match → fuzzy (existing order).
|
|
35
33
|
* Stable within each group. Exported for testing.
|
|
@@ -56,7 +54,6 @@ export function rankCommands(input, options) {
|
|
|
56
54
|
}
|
|
57
55
|
return [...exact, ...prefix, ...fuzzy];
|
|
58
56
|
}
|
|
59
|
-
// ── Autocomplete dropdown (context-sensitive, shown while typing) ────
|
|
60
57
|
export function buildCommands(context) {
|
|
61
58
|
const commands = [...globalCommands()];
|
|
62
59
|
if (context.activeTab === 'loops') {
|
|
@@ -95,7 +92,6 @@ export function buildCommands(context) {
|
|
|
95
92
|
}
|
|
96
93
|
return commands;
|
|
97
94
|
}
|
|
98
|
-
// ── Ctrl+P commands browser (shows all available commands for this tab) ─
|
|
99
95
|
export function buildTabCommands(context) {
|
|
100
96
|
const commands = [...globalCommands()];
|
|
101
97
|
if (context.activeTab === 'loops') {
|
|
@@ -7,13 +7,22 @@ import { useUndoRedo } from "../../shared/useUndoRedo.js";
|
|
|
7
7
|
import { highlightSegments } from "../utils/syntax.js";
|
|
8
8
|
import { joinCommandLines } from "../../loop-config.js";
|
|
9
9
|
import { copyToClipboard, readFromClipboard } from "../../shared/clipboard.js";
|
|
10
|
-
import {
|
|
11
|
-
import { CODE_EDITOR_MODAL_HEIGHT, CODE_EDITOR_UNDO_LIMIT, CODE_EDITOR_SYNTAX_COLORS, } from "../../config/constants.js";
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
10
|
+
import { sanitizeMultilinePaste } from "../utils/paste.js";
|
|
11
|
+
import { CODE_EDITOR_MODAL_HEIGHT, CODE_EDITOR_MODAL_WIDTH, CODE_EDITOR_UNDO_LIMIT, CODE_EDITOR_SYNTAX_COLORS, } from "../../config/constants.js";
|
|
12
|
+
// Cap modal to terminal size: never exceed (terminal - 2) so the border
|
|
13
|
+
// and a 1-row margin are always visible. Ideal max is the constant.
|
|
14
|
+
function useModalDimensions() {
|
|
15
|
+
const termRows = process.stdout.rows || 24;
|
|
16
|
+
const termCols = process.stdout.columns || 80;
|
|
17
|
+
const modalHeight = Math.min(CODE_EDITOR_MODAL_HEIGHT, termRows - 2);
|
|
18
|
+
const modalWidth = Math.min(CODE_EDITOR_MODAL_WIDTH, termCols - 2);
|
|
19
|
+
// total height - title - preview - buttons - hint - 2 padding = -6
|
|
20
|
+
const editorVisibleLines = Math.max(3, modalHeight - 6);
|
|
21
|
+
return { modalWidth, modalHeight, editorVisibleLines };
|
|
22
|
+
}
|
|
15
23
|
export function CodeEditorModal(props) {
|
|
16
24
|
const { initialValue, onSave, onCancel } = props;
|
|
25
|
+
const { modalWidth, modalHeight, editorVisibleLines: EDITOR_VISIBLE_LINES } = useModalDimensions();
|
|
17
26
|
const { value, setValue, undo, redo } = useUndoRedo(initialValue, CODE_EDITOR_UNDO_LIMIT);
|
|
18
27
|
const [cursorRow, setCursorRow] = useState(() => {
|
|
19
28
|
const lines = initialValue.split("\n");
|
|
@@ -49,18 +58,32 @@ export function CodeEditorModal(props) {
|
|
|
49
58
|
}, [setValue]);
|
|
50
59
|
// ---- Keyboard handling ----
|
|
51
60
|
useInput((input, key) => {
|
|
61
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
|
|
62
|
+
// Must be detected BEFORE the escape check — the leading ESC trips
|
|
63
|
+
// key.escape and would close the modal before paste is handled.
|
|
64
|
+
if (input.includes("\x1b[200~")) {
|
|
65
|
+
const pasted = sanitizeMultilinePaste(input);
|
|
66
|
+
if (pasted) {
|
|
67
|
+
const next = [...lines];
|
|
68
|
+
const line = next[cursorRow] ?? "";
|
|
69
|
+
next[cursorRow] =
|
|
70
|
+
line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
71
|
+
applyMutation(next, cursorRow, cursorCol + pasted.length);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
52
75
|
// Esc → cancel
|
|
53
76
|
if (key.escape) {
|
|
54
77
|
onCancel();
|
|
55
78
|
return;
|
|
56
79
|
}
|
|
57
|
-
// Ctrl+S → save
|
|
58
|
-
if (key.ctrl && input === "s") {
|
|
80
|
+
// Ctrl+S → save (also accept raw \x13 fallback)
|
|
81
|
+
if ((key.ctrl && input === "s") || input === "\x13") {
|
|
59
82
|
onSave(value);
|
|
60
83
|
return;
|
|
61
84
|
}
|
|
62
|
-
// Ctrl+Z → undo
|
|
63
|
-
if (key.ctrl && !key.shift && input === "z") {
|
|
85
|
+
// Ctrl+Z → undo (also accept raw \x1a fallback)
|
|
86
|
+
if ((key.ctrl && !key.shift && input === "z") || input === "\x1a") {
|
|
64
87
|
undo();
|
|
65
88
|
return;
|
|
66
89
|
}
|
|
@@ -69,8 +92,8 @@ export function CodeEditorModal(props) {
|
|
|
69
92
|
redo();
|
|
70
93
|
return;
|
|
71
94
|
}
|
|
72
|
-
// Ctrl+X → cut (copy all to clipboard, then clear)
|
|
73
|
-
if (key.ctrl && input === "x") {
|
|
95
|
+
// Ctrl+X → cut (copy all to clipboard, then clear; also accept \x18)
|
|
96
|
+
if ((key.ctrl && input === "x") || input === "\x18") {
|
|
74
97
|
copyToClipboard(value);
|
|
75
98
|
setValue("");
|
|
76
99
|
setCursorRow(0);
|
|
@@ -78,11 +101,12 @@ export function CodeEditorModal(props) {
|
|
|
78
101
|
setFlashMsg(t("codeEditor.copied"));
|
|
79
102
|
return;
|
|
80
103
|
}
|
|
81
|
-
// Ctrl+V → paste from clipboard
|
|
82
|
-
|
|
104
|
+
// Ctrl+V → paste from clipboard (also accept raw \x16 fallback for
|
|
105
|
+
// terminals that don't set key.ctrl on the SYN control byte)
|
|
106
|
+
if ((key.ctrl && input === "v") || input === "\x16") {
|
|
83
107
|
const clip = readFromClipboard();
|
|
84
108
|
if (clip) {
|
|
85
|
-
const pasted =
|
|
109
|
+
const pasted = sanitizeMultilinePaste(clip);
|
|
86
110
|
if (pasted) {
|
|
87
111
|
const next = [...lines];
|
|
88
112
|
const line = next[cursorRow] ?? "";
|
|
@@ -91,10 +115,15 @@ export function CodeEditorModal(props) {
|
|
|
91
115
|
applyMutation(next, cursorRow, cursorCol + pasted.length);
|
|
92
116
|
}
|
|
93
117
|
}
|
|
118
|
+
else {
|
|
119
|
+
// No clipboard tool available (common over SSH). Bracketed paste
|
|
120
|
+
// (right-click in the terminal) still works — point the user at it.
|
|
121
|
+
setFlashMsg(t("codeEditor.clipboardEmpty"));
|
|
122
|
+
}
|
|
94
123
|
return;
|
|
95
124
|
}
|
|
96
125
|
// Ctrl+L → clear all
|
|
97
|
-
if (key.ctrl && input === "l") {
|
|
126
|
+
if ((key.ctrl && input === "l") || input === "\x0c") {
|
|
98
127
|
setValue("");
|
|
99
128
|
setCursorRow(0);
|
|
100
129
|
setCursorCol(0);
|
|
@@ -165,24 +194,10 @@ export function CodeEditorModal(props) {
|
|
|
165
194
|
}
|
|
166
195
|
return;
|
|
167
196
|
}
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
const pasted = sanitizePaste(input);
|
|
171
|
-
if (pasted) {
|
|
172
|
-
const next = [...lines];
|
|
173
|
-
const line = next[cursorRow] ?? "";
|
|
174
|
-
next[cursorRow] =
|
|
175
|
-
line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
|
|
176
|
-
applyMutation(next, cursorRow, cursorCol + pasted.length);
|
|
177
|
-
}
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
// Multi-char containing CR/LF with no bracketed markers — ignore
|
|
181
|
-
if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
|
|
182
|
-
return;
|
|
183
|
-
// Multi-char printable input = unbracketed single-line paste
|
|
197
|
+
// Multi-char printable input = unbracketed paste (e.g. right-click
|
|
198
|
+
// in terminals that don't support DECSET 2004). Preserve newlines.
|
|
184
199
|
if (input.length > 1 && !key.meta) {
|
|
185
|
-
const pasted =
|
|
200
|
+
const pasted = sanitizeMultilinePaste(input);
|
|
186
201
|
if (pasted) {
|
|
187
202
|
const next = [...lines];
|
|
188
203
|
const line = next[cursorRow] ?? "";
|
|
@@ -203,12 +218,12 @@ export function CodeEditorModal(props) {
|
|
|
203
218
|
}, { isActive: true });
|
|
204
219
|
// ---- Live preview footer ----
|
|
205
220
|
const joined = joinCommandLines(value);
|
|
206
|
-
const innerWidth =
|
|
221
|
+
const innerWidth = modalWidth - 2;
|
|
207
222
|
const truncatedPreview = joined.length > innerWidth
|
|
208
223
|
? joined.slice(0, innerWidth - 1) + t("codeEditor.previewTruncated")
|
|
209
224
|
: joined;
|
|
210
225
|
const accent = theme.accent.brand;
|
|
211
|
-
return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width:
|
|
226
|
+
return (_jsx(Box, { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { width: modalWidth, height: modalHeight, flexDirection: "column", backgroundColor: theme.bg.elevated, borderStyle: "round", borderColor: accent, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: accent, bold: true, children: t("codeEditor.title") }), _jsxs(Text, { color: theme.text.muted, children: [lineCount, " ", t("cmdEditor.lines")] })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: visibleLines.map((line, visIdx) => {
|
|
212
227
|
const rowIdx = scrollStart + visIdx;
|
|
213
228
|
const isCursor = rowIdx === cursorRow;
|
|
214
229
|
const lineNum = String(rowIdx + 1).padStart(lineNumWidth, " ");
|
|
@@ -8,7 +8,6 @@ import { buildCommands, rankCommands } from "../commands.js";
|
|
|
8
8
|
import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, } from "../../config/constants.js";
|
|
9
9
|
import { sanitizePaste } from "../utils/paste.js";
|
|
10
10
|
export { sanitizePaste } from "../utils/paste.js";
|
|
11
|
-
// ── Rendered input with cursor ───────────────────────────────────────
|
|
12
11
|
function renderInputLine(value, cursorOffset) {
|
|
13
12
|
if (value.length === 0) {
|
|
14
13
|
return "\x1b[7m \x1b[27m";
|
|
@@ -54,7 +53,6 @@ function renderHighlightedLabel(label, matchRanges, isFocused) {
|
|
|
54
53
|
}
|
|
55
54
|
return result;
|
|
56
55
|
}
|
|
57
|
-
// ── Dropdown ─────────────────────────────────────────────────────────
|
|
58
56
|
function CommandDropdown({ state, rankedFiltered, }) {
|
|
59
57
|
if (!state.isOpen || state.isLoading || state.error)
|
|
60
58
|
return null;
|
|
@@ -75,15 +73,12 @@ function CommandDropdown({ state, rankedFiltered, }) {
|
|
|
75
73
|
return (_jsxs(Box, { backgroundColor: isFocused ? theme.bg.active : undefined, children: [_jsx(Text, { color: isFocused ? theme.text.inverse : theme.text.muted, children: isFocused ? "\u276f " : " " }), _jsx(Text, { color: isFocused ? theme.text.inverse : undefined, children: label })] }, match.option.value));
|
|
76
74
|
}), belowCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2193 ${belowCount} more` }))] }));
|
|
77
75
|
}
|
|
78
|
-
// ── Confirm inline options ────────────────────────────────────────────
|
|
79
|
-
// ── Hint bar ──────────────────────────────────────────────────────────
|
|
80
76
|
function HintBar({ leftHint, rightHint, }) {
|
|
81
77
|
return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Box, { children: leftHint }), _jsx(Box, { children: rightHint })] }));
|
|
82
78
|
}
|
|
83
79
|
function KeyHint({ keyLabel, action }) {
|
|
84
80
|
return (_jsxs(Box, { marginRight: 2, children: [_jsx(Text, { bold: true, color: theme.text.primary, children: keyLabel }), _jsx(Text, { color: theme.text.muted, children: " " + action })] }));
|
|
85
81
|
}
|
|
86
|
-
// ── Command mode ─────────────────────────────────────────────────────
|
|
87
82
|
function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navOwner, onInputStateChange, }) {
|
|
88
83
|
const commands = useMemo(() => buildCommands(context), [context]);
|
|
89
84
|
const options = useMemo(() => commands.map((cmd) => ({ label: cmd.label, value: cmd.value })), [commands]);
|
|
@@ -210,7 +205,6 @@ function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navO
|
|
|
210
205
|
const inputContent = isEmpty ? cursor : renderInputLine(state.inputValue, state.cursorOffset);
|
|
211
206
|
return (_jsxs(_Fragment, { children: [_jsx(CommandDropdown, { state: state, rankedFiltered: rankedFiltered }), _jsxs(Box, { children: [_jsx(Text, { color: theme.accent.brand, children: "│ " }), isEmpty ? (_jsx(Text, { color: theme.text.muted, children: t("cmdInput.placeholder") })) : (_jsx(Text, { children: inputContent }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsxs(Box, { children: [_jsx(KeyHint, { keyLabel: "enter", action: "edit/logs" }), _jsx(KeyHint, { keyLabel: "ctrl+u", action: "clear" }), _jsx(KeyHint, { keyLabel: "c", action: "copy" }), _jsx(KeyHint, { keyLabel: "tab", action: "panels" }), _jsx(KeyHint, { keyLabel: "ctrl+\u2190\u2192", action: "tabs" }), _jsx(KeyHint, { keyLabel: "ctrl+p", action: "commands" })] }) })] }));
|
|
212
207
|
}
|
|
213
|
-
// ── Confirm mode ─────────────────────────────────────────────────────
|
|
214
208
|
function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, }) {
|
|
215
209
|
const [value, setValue] = useState("");
|
|
216
210
|
useInput((input, key) => {
|
|
@@ -246,7 +240,6 @@ function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, })
|
|
|
246
240
|
const cursor = "\x1b[7m \x1b[27m";
|
|
247
241
|
return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.muted, children: confirmState.prompt + " " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { children: cursor }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
|
|
248
242
|
}
|
|
249
|
-
// ── Search mode ──────────────────────────────────────────────────────
|
|
250
243
|
function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, disabled, }) {
|
|
251
244
|
useInput((input, key) => {
|
|
252
245
|
if (key.ctrl)
|
|
@@ -274,7 +267,6 @@ function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, dis
|
|
|
274
267
|
const cursor = "\x1b[7m \x1b[27m";
|
|
275
268
|
return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent.project, children: "│ " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { color: theme.text.muted, children: placeholder }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "apply" }) })] }));
|
|
276
269
|
}
|
|
277
|
-
// ── Main component ───────────────────────────────────────────────────
|
|
278
270
|
export function CommandInput(props) {
|
|
279
271
|
const { context, onCommand, confirmState, searchState, searchValue, onSearchChange, onSearchSubmit, onSearchCancel, onConfirmYes, onConfirmCancel, onCopy, onPanelAction, navOwner, onInputStateChange, } = props;
|
|
280
272
|
return (_jsx(Box, { flexDirection: "column", height: COMMAND_INPUT_HEIGHT, borderStyle: "single", borderColor: theme.border.dim, paddingY: 1, children: searchState?.active ? (_jsx(SearchMode, { value: searchValue, onSearchChange: onSearchChange, onSearchSubmit: onSearchSubmit, onSearchCancel: onSearchCancel, disabled: props.disabled })) : confirmState === null ? (_jsx(CommandMode, { context: context, onCommand: onCommand, onCopy: onCopy, onPanelAction: onPanelAction, disabled: props.disabled, navOwner: navOwner, onInputStateChange: onInputStateChange })) : (_jsx(ConfirmMode, { confirmState: confirmState, onConfirmYes: onConfirmYes, onConfirmCancel: onConfirmCancel, disabled: props.disabled })) }));
|
|
@@ -9,7 +9,6 @@ import { CodeEditorPreview } from "./CodeEditorPreview.js";
|
|
|
9
9
|
import { CodeEditorModal } from "./CodeEditorModal.js";
|
|
10
10
|
import { parseDuration } from "../../duration.js";
|
|
11
11
|
import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
|
|
12
|
-
// ── Component ───────────────────────────────────────────────────────
|
|
13
12
|
export function CreateView(props) {
|
|
14
13
|
const { mode, editId, initial, selectedTaskId, selectedTaskName, tasks, currentProjectId, onCancel, onDone, onChooseTask, } = props;
|
|
15
14
|
const [taskPickerOpen, setTaskPickerOpen] = useState(false);
|
|
@@ -14,6 +14,12 @@ export function ExportModal(props) {
|
|
|
14
14
|
const maxScroll = Math.max(0, displayLines.length - VISIBLE_LINES);
|
|
15
15
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
16
16
|
useInput((input, key) => {
|
|
17
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
|
|
18
|
+
// before the escape check — the leading ESC trips key.escape and would
|
|
19
|
+
// close the modal on a right-click paste.
|
|
20
|
+
if (input.includes("\x1b[200~")) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
17
23
|
if (key.escape) {
|
|
18
24
|
props.onClose();
|
|
19
25
|
return;
|
|
@@ -54,6 +54,13 @@ export function LogModal(props) {
|
|
|
54
54
|
const endIdx = startIdx + MAX_VISIBLE_LINES;
|
|
55
55
|
const visible = filtered.slice(startIdx, endIdx);
|
|
56
56
|
useInput((input, key) => {
|
|
57
|
+
// Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
|
|
58
|
+
// before the escape check — the leading ESC trips key.escape and would
|
|
59
|
+
// close the modal before the paste is acknowledged. LogModal doesn't
|
|
60
|
+
// insert pastes, so just swallow the sequence.
|
|
61
|
+
if (input.includes("\x1b[200~")) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
57
64
|
if (searchMode) {
|
|
58
65
|
if (key.escape || key.return) {
|
|
59
66
|
setSearchMode(false);
|
|
@@ -7,8 +7,7 @@ import { CodeEditorModal } from "./CodeEditorModal.js";
|
|
|
7
7
|
import { createTask, updateTask, listTasks } from "../daemon.js";
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
9
|
import { t } from "../../i18n/index.js";
|
|
10
|
-
import { joinCommandLines } from "../../loop-config.js";
|
|
11
|
-
// ── Utility ─────────────────────────────────────────────────────────
|
|
10
|
+
import { joinCommandLines, parseCommandLine } from "../../loop-config.js";
|
|
12
11
|
function parseArgs(cmd) {
|
|
13
12
|
const tokens = [];
|
|
14
13
|
const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
@@ -21,11 +20,14 @@ function parseArgs(cmd) {
|
|
|
21
20
|
function joinCommand(task) {
|
|
22
21
|
return [task.command, ...task.commandArgs].join(" ");
|
|
23
22
|
}
|
|
24
|
-
// ── Component ───────────────────────────────────────────────────────
|
|
25
23
|
export function TaskForm(props) {
|
|
26
24
|
const { mode, editTask, onCancel, onDone } = props;
|
|
27
25
|
const [tasks, setTasks] = useState([]);
|
|
28
|
-
const [commandValue, setCommandValue] = useState(editTask
|
|
26
|
+
const [commandValue, setCommandValue] = useState(editTask
|
|
27
|
+
? (editTask.commands?.length
|
|
28
|
+
? editTask.commands.map(c => c.commandRaw ?? [c.command, ...c.commandArgs].join(" ")).join("\n&&\n")
|
|
29
|
+
: (editTask.commandRaw ?? joinCommand(editTask)))
|
|
30
|
+
: "");
|
|
29
31
|
useEffect(() => {
|
|
30
32
|
listTasks().then(setTasks).catch(() => setTasks([]));
|
|
31
33
|
}, []);
|
|
@@ -100,14 +102,44 @@ export function TaskForm(props) {
|
|
|
100
102
|
return;
|
|
101
103
|
const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
|
|
102
104
|
const onFailureTaskId = resolveChainId(values.onFailure ?? "");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
// Split by && to build parallel commands. If only one command, keep
|
|
106
|
+
// backward compat (command + commandArgs, no commands[]).
|
|
107
|
+
const commandSegments = commandValue
|
|
108
|
+
.split(/\n&&\n|\n&&|&&\n|&&/)
|
|
109
|
+
.map(s => s.trim())
|
|
110
|
+
.filter(s => s.length > 0);
|
|
111
|
+
let payload;
|
|
112
|
+
if (commandSegments.length > 1) {
|
|
113
|
+
const commands = commandSegments.slice(0, 4).map(seg => {
|
|
114
|
+
const joined = joinCommandLines(seg);
|
|
115
|
+
const tokens = parseCommandLine(joined);
|
|
116
|
+
return {
|
|
117
|
+
command: tokens[0] ?? "",
|
|
118
|
+
commandArgs: tokens.slice(1),
|
|
119
|
+
commandRaw: seg,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
// First command also populates command/commandArgs for backward compat
|
|
123
|
+
payload = {
|
|
124
|
+
name,
|
|
125
|
+
command: commands[0].command,
|
|
126
|
+
commandArgs: commands[0].commandArgs,
|
|
127
|
+
commandRaw: commands[0].commandRaw,
|
|
128
|
+
commands,
|
|
129
|
+
onSuccessTaskId,
|
|
130
|
+
onFailureTaskId,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
payload = {
|
|
135
|
+
name,
|
|
136
|
+
command: rawCommand.split(" ")[0] ?? "",
|
|
137
|
+
commandArgs: parseArgs(rawCommand),
|
|
138
|
+
commandRaw: commandValue,
|
|
139
|
+
onSuccessTaskId,
|
|
140
|
+
onFailureTaskId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
111
143
|
if (mode === "edit" && editTask) {
|
|
112
144
|
updateTask(editTask.id, payload)
|
|
113
145
|
.then(() => onDone(true, editTask.id))
|
package/dist/tui/daemon.js
CHANGED
package/dist/tui/utils/paste.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { PASTE_MAX_CHARS } from "../../config/constants.js";
|
|
2
|
-
// Turn a raw paste (possibly bracketed, multi-line, with stray control chars)
|
|
3
|
-
// into a single-line insertable string. Exported for tests.
|
|
4
2
|
export function sanitizePaste(raw) {
|
|
5
3
|
return raw
|
|
6
|
-
.replace(/\x1b\[20[01]~/g, "")
|
|
7
|
-
.replace(/[\r\n]+/g, " ")
|
|
8
|
-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
4
|
+
.replace(/\x1b\[20[01]~/g, "")
|
|
5
|
+
.replace(/[\r\n]+/g, " ")
|
|
6
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
7
|
+
.slice(0, PASTE_MAX_CHARS);
|
|
8
|
+
}
|
|
9
|
+
export function sanitizeMultilinePaste(raw) {
|
|
10
|
+
return raw
|
|
11
|
+
.replace(/\x1b\[20[01]~/g, "")
|
|
12
|
+
.replace(/\r\n/g, "\n")
|
|
13
|
+
.replace(/\r/g, "\n")
|
|
14
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
9
15
|
.slice(0, PASTE_MAX_CHARS);
|
|
10
16
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loop-task",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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": {
|