tmux-agent 0.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/.codex/skills/speckit/SKILL.md +173 -0
- package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
- package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
- package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
- package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
- package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
- package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
- package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
- package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
- package/.codex/skills/speckit/references/acceptance.md +183 -0
- package/.codex/skills/speckit/references/analyze.md +186 -0
- package/.codex/skills/speckit/references/checklist.md +302 -0
- package/.codex/skills/speckit/references/clarify-auto.md +69 -0
- package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
- package/.codex/skills/speckit/references/clarify.md +189 -0
- package/.codex/skills/speckit/references/constitution.md +90 -0
- package/.codex/skills/speckit/references/group.md +89 -0
- package/.codex/skills/speckit/references/implement-task.md +115 -0
- package/.codex/skills/speckit/references/implement.md +129 -0
- package/.codex/skills/speckit/references/notes.md +82 -0
- package/.codex/skills/speckit/references/plan-deep.md +87 -0
- package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
- package/.codex/skills/speckit/references/plan-from-review.md +89 -0
- package/.codex/skills/speckit/references/plan.md +97 -0
- package/.codex/skills/speckit/references/review-plan.md +156 -0
- package/.codex/skills/speckit/references/specify.md +246 -0
- package/.codex/skills/speckit/references/tasks.md +155 -0
- package/.codex/skills/speckit/references/taskstoissues.md +33 -0
- package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
- package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
- package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
- package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
- package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
- package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
- package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
- package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
- package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
- package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
- package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
- package/.specify/memory/constitution.md +67 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +49 -0
- package/.specify/templates/plan-template.md +126 -0
- package/.specify/templates/spec-template.md +135 -0
- package/.specify/templates/tasks-template.md +269 -0
- package/README.md +128 -0
- package/README.zh-CN.md +127 -0
- package/bun.lock +269 -0
- package/dist/cli/commands/codex/forkHome.js +88 -0
- package/dist/cli/commands/codex/send.js +55 -0
- package/dist/cli/commands/codex/sessionInfo.js +42 -0
- package/dist/cli/commands/codex/spawn.js +68 -0
- package/dist/cli/commands/find.js +26 -0
- package/dist/cli/commands/paneKill.js +33 -0
- package/dist/cli/commands/paneSpawn.js +40 -0
- package/dist/cli/commands/paneTitle.js +33 -0
- package/dist/cli/commands/read.js +34 -0
- package/dist/cli/commands/send.js +51 -0
- package/dist/cli/commands/snapshot.js +19 -0
- package/dist/cli/commands/ui/select.js +41 -0
- package/dist/cli/commands/windowKill.js +25 -0
- package/dist/cli/commands/windowLs.js +15 -0
- package/dist/cli/commands/windowNew.js +28 -0
- package/dist/cli/commands/windowRename.js +25 -0
- package/dist/cli/index.js +365 -0
- package/dist/cli/parse.js +39 -0
- package/dist/lib/codex/forkHome.js +101 -0
- package/dist/lib/codex/isCodexPane.js +55 -0
- package/dist/lib/codex/send.js +58 -0
- package/dist/lib/codex/sessionInfo.js +449 -0
- package/dist/lib/codex/spawn.js +246 -0
- package/dist/lib/contracts/types.js +2 -0
- package/dist/lib/fs/safeRm.js +32 -0
- package/dist/lib/io/readStdin.js +14 -0
- package/dist/lib/os/process.js +55 -0
- package/dist/lib/output/format.js +95 -0
- package/dist/lib/proc/lsof.js +42 -0
- package/dist/lib/proc/ps.js +60 -0
- package/dist/lib/targeting/errors.js +13 -0
- package/dist/lib/targeting/resolvePaneTarget.js +91 -0
- package/dist/lib/targeting/resolveWindowTarget.js +40 -0
- package/dist/lib/targeting/scope.js +58 -0
- package/dist/lib/tmux/capturePane.js +20 -0
- package/dist/lib/tmux/exec.js +66 -0
- package/dist/lib/tmux/paneOps.js +29 -0
- package/dist/lib/tmux/paste.js +23 -0
- package/dist/lib/tmux/sendKeys.js +47 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/tmux/snapshotPanes.js +46 -0
- package/dist/lib/tmux/snapshotWindows.js +24 -0
- package/dist/lib/tmux/windowOps.js +32 -0
- package/dist/lib/ui/popupSelect.js +432 -0
- package/dist/lib/ui/popupSupport.js +76 -0
- package/package.json +23 -0
- package/src/cli/commands/codex/forkHome.ts +141 -0
- package/src/cli/commands/codex/send.ts +83 -0
- package/src/cli/commands/codex/sessionInfo.ts +59 -0
- package/src/cli/commands/codex/spawn.ts +90 -0
- package/src/cli/commands/find.ts +40 -0
- package/src/cli/commands/paneKill.ts +49 -0
- package/src/cli/commands/paneSpawn.ts +53 -0
- package/src/cli/commands/paneTitle.ts +50 -0
- package/src/cli/commands/read.ts +48 -0
- package/src/cli/commands/send.ts +71 -0
- package/src/cli/commands/snapshot.ts +28 -0
- package/src/cli/commands/ui/select.ts +49 -0
- package/src/cli/commands/windowKill.ts +35 -0
- package/src/cli/commands/windowLs.ts +20 -0
- package/src/cli/commands/windowNew.ts +40 -0
- package/src/cli/commands/windowRename.ts +36 -0
- package/src/cli/index.ts +430 -0
- package/src/lib/codex/forkHome.ts +148 -0
- package/src/lib/codex/isCodexPane.ts +56 -0
- package/src/lib/codex/send.ts +84 -0
- package/src/lib/codex/sessionInfo.ts +521 -0
- package/src/lib/codex/spawn.ts +305 -0
- package/src/lib/contracts/types.ts +30 -0
- package/src/lib/fs/safeRm.ts +32 -0
- package/src/lib/io/readStdin.ts +11 -0
- package/src/lib/output/format.ts +105 -0
- package/src/lib/proc/lsof.ts +44 -0
- package/src/lib/proc/ps.ts +70 -0
- package/src/lib/targeting/errors.ts +25 -0
- package/src/lib/targeting/resolvePaneTarget.ts +106 -0
- package/src/lib/targeting/resolveWindowTarget.ts +45 -0
- package/src/lib/targeting/scope.ts +76 -0
- package/src/lib/tmux/capturePane.ts +21 -0
- package/src/lib/tmux/exec.ts +90 -0
- package/src/lib/tmux/paneOps.ts +35 -0
- package/src/lib/tmux/paste.ts +20 -0
- package/src/lib/tmux/sendKeys.ts +72 -0
- package/src/lib/tmux/session.ts +27 -0
- package/src/lib/tmux/snapshotPanes.ts +52 -0
- package/src/lib/tmux/snapshotWindows.ts +23 -0
- package/src/lib/tmux/windowOps.ts +43 -0
- package/src/lib/ui/popupSelect.ts +561 -0
- package/src/lib/ui/popupSupport.ts +84 -0
- package/tests/e2e/codexForkHome.test.ts +146 -0
- package/tests/e2e/codexSessionInfo.test.ts +112 -0
- package/tests/e2e/codexTuiSend.test.ts +68 -0
- package/tests/integration/codexSpawn.test.ts +113 -0
- package/tests/integration/paneOps.test.ts +60 -0
- package/tests/integration/sendRead.test.ts +52 -0
- package/tests/integration/snapshot.test.ts +39 -0
- package/tests/integration/tmuxHarness.ts +39 -0
- package/tests/integration/windowOps.test.ts +60 -0
- package/tests/unit/codexSend.test.ts +105 -0
- package/tests/unit/codexSessionInfo.test.ts +88 -0
- package/tests/unit/codexSpawn.test.ts +34 -0
- package/tests/unit/keys.test.ts +30 -0
- package/tests/unit/outputFormat.test.ts +52 -0
- package/tests/unit/popupSelect.test.ts +77 -0
- package/tests/unit/popupSupport.test.ts +109 -0
- package/tests/unit/resolvePaneTarget.test.ts +43 -0
- package/tests/unit/resolveWindowTarget.test.ts +36 -0
- package/tests/unit/safeRm.test.ts +41 -0
- package/tests/unit/scope.test.ts +57 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseKeyExpression = parseKeyExpression;
|
|
4
|
+
exports.classifySendInput = classifySendInput;
|
|
5
|
+
exports.sendKeys = sendKeys;
|
|
6
|
+
const exec_1 = require("./exec");
|
|
7
|
+
const paste_1 = require("./paste");
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
function parseKeyExpression(input) {
|
|
12
|
+
const trimmed = input.trim();
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (trimmed.toLowerCase() === "enter") {
|
|
17
|
+
return "Enter";
|
|
18
|
+
}
|
|
19
|
+
const ctrlMatch = trimmed.match(/^Ctrl\+([A-Za-z])$/);
|
|
20
|
+
if (ctrlMatch) {
|
|
21
|
+
return `C-${ctrlMatch[1].toLowerCase()}`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function classifySendInput(input, noEnter) {
|
|
26
|
+
const tmuxKey = parseKeyExpression(input);
|
|
27
|
+
if (tmuxKey) {
|
|
28
|
+
return { mode: "keys", tmuxKey, enter: false };
|
|
29
|
+
}
|
|
30
|
+
return { mode: "text", enter: !noEnter };
|
|
31
|
+
}
|
|
32
|
+
async function sendKeys(paneId, input, options = {}) {
|
|
33
|
+
const { mode, tmuxKey, enter } = classifySendInput(input, options.noEnter ?? false);
|
|
34
|
+
if (mode === "keys" && tmuxKey) {
|
|
35
|
+
await (0, exec_1.tmuxExec)(["send-keys", "-t", paneId, tmuxKey]);
|
|
36
|
+
return { mode, text: input, enter };
|
|
37
|
+
}
|
|
38
|
+
await (0, paste_1.pasteText)(paneId, input);
|
|
39
|
+
if (enter) {
|
|
40
|
+
const enterDelayMs = options.enterDelayMs ?? 0;
|
|
41
|
+
if (enterDelayMs > 0) {
|
|
42
|
+
await sleep(enterDelayMs);
|
|
43
|
+
}
|
|
44
|
+
await (0, exec_1.tmuxExec)(["send-keys", "-t", paneId, "Enter"]);
|
|
45
|
+
}
|
|
46
|
+
return { mode: "text", text: input, enter };
|
|
47
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveSessionId = resolveSessionId;
|
|
4
|
+
const exec_1 = require("./exec");
|
|
5
|
+
async function resolveSessionId(sessionName) {
|
|
6
|
+
const result = await (0, exec_1.tmuxExec)([
|
|
7
|
+
"list-sessions",
|
|
8
|
+
"-F",
|
|
9
|
+
"#{session_name}\t#{session_id}"
|
|
10
|
+
]);
|
|
11
|
+
const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
12
|
+
let fallbackId;
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const [name, id] = line.split("\t");
|
|
15
|
+
if (name === sessionName && id) {
|
|
16
|
+
return id;
|
|
17
|
+
}
|
|
18
|
+
if (!fallbackId && id && name.endsWith(sessionName)) {
|
|
19
|
+
const prefix = name.slice(0, name.length - sessionName.length);
|
|
20
|
+
if (/^\d+-$/.test(prefix)) {
|
|
21
|
+
fallbackId = id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (fallbackId) {
|
|
26
|
+
return fallbackId;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`can't find session: ${sessionName}`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.snapshotPanes = snapshotPanes;
|
|
4
|
+
const exec_1 = require("./exec");
|
|
5
|
+
const session_1 = require("./session");
|
|
6
|
+
async function snapshotPanes(options) {
|
|
7
|
+
const sessionId = options.session
|
|
8
|
+
? await (0, session_1.resolveSessionId)(options.session)
|
|
9
|
+
: undefined;
|
|
10
|
+
const args = ["list-panes"];
|
|
11
|
+
if (options.windowId) {
|
|
12
|
+
args.push("-t", options.windowId);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
args.push("-a");
|
|
16
|
+
}
|
|
17
|
+
args.push("-F", "#{session_id}\t#{pane_index}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{pane_active}\t#{pane_dead}\t#{window_id}");
|
|
18
|
+
const result = await (0, exec_1.tmuxExec)(args);
|
|
19
|
+
const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
20
|
+
return lines
|
|
21
|
+
.map((line) => {
|
|
22
|
+
const [lineSessionId, idxRaw, id, pidRaw, command, title, activeRaw, deadRaw, windowId] = line.split("\t");
|
|
23
|
+
const idx = Number(idxRaw);
|
|
24
|
+
const pidValue = Number(pidRaw);
|
|
25
|
+
const pid = Number.isFinite(pidValue) ? pidValue : undefined;
|
|
26
|
+
let status = "idle";
|
|
27
|
+
if (deadRaw === "1") {
|
|
28
|
+
status = "dead";
|
|
29
|
+
}
|
|
30
|
+
else if (activeRaw === "1") {
|
|
31
|
+
status = "active";
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
sessionId: lineSessionId,
|
|
35
|
+
idx,
|
|
36
|
+
id,
|
|
37
|
+
pid,
|
|
38
|
+
command,
|
|
39
|
+
title,
|
|
40
|
+
status,
|
|
41
|
+
windowId
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
.filter((pane) => (sessionId ? pane.sessionId === sessionId : true))
|
|
45
|
+
.map(({ sessionId: _sessionId, ...pane }) => pane);
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.snapshotWindows = snapshotWindows;
|
|
4
|
+
const exec_1 = require("./exec");
|
|
5
|
+
const session_1 = require("./session");
|
|
6
|
+
async function snapshotWindows(session) {
|
|
7
|
+
const sessionId = await (0, session_1.resolveSessionId)(session);
|
|
8
|
+
const result = await (0, exec_1.tmuxExec)([
|
|
9
|
+
"list-windows",
|
|
10
|
+
"-a",
|
|
11
|
+
"-F",
|
|
12
|
+
"#{session_id}\t#{window_index}\t#{window_id}\t#{window_name}\t#{window_active}"
|
|
13
|
+
]);
|
|
14
|
+
const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
15
|
+
return lines
|
|
16
|
+
.map((line) => {
|
|
17
|
+
const [lineSessionId, widxRaw, wid, name, activeRaw] = line.split("\t");
|
|
18
|
+
const widx = Number(widxRaw);
|
|
19
|
+
const status = activeRaw === "1" ? "active" : "inactive";
|
|
20
|
+
return { sessionId: lineSessionId, widx, wid, name, status };
|
|
21
|
+
})
|
|
22
|
+
.filter((window) => window.sessionId === sessionId)
|
|
23
|
+
.map(({ sessionId: _sessionId, ...window }) => window);
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.windowNew = windowNew;
|
|
4
|
+
exports.windowRename = windowRename;
|
|
5
|
+
exports.windowKill = windowKill;
|
|
6
|
+
const exec_1 = require("./exec");
|
|
7
|
+
async function windowNew(session, name) {
|
|
8
|
+
const args = [
|
|
9
|
+
"new-window",
|
|
10
|
+
"-P",
|
|
11
|
+
"-F",
|
|
12
|
+
"#{window_id}\t#{window_index}\t#{window_name}\t#{pane_id}",
|
|
13
|
+
"-t",
|
|
14
|
+
session,
|
|
15
|
+
"-n",
|
|
16
|
+
name
|
|
17
|
+
];
|
|
18
|
+
const result = await (0, exec_1.tmuxExec)(args);
|
|
19
|
+
const [wid, widxRaw, windowName, paneId] = result.stdout.trim().split("\t");
|
|
20
|
+
return {
|
|
21
|
+
wid,
|
|
22
|
+
widx: Number(widxRaw),
|
|
23
|
+
name: windowName || name,
|
|
24
|
+
paneId
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function windowRename(windowId, name) {
|
|
28
|
+
await (0, exec_1.tmuxExec)(["rename-window", "-t", windowId, name]);
|
|
29
|
+
}
|
|
30
|
+
async function windowKill(windowId) {
|
|
31
|
+
await (0, exec_1.tmuxExec)(["kill-window", "-t", windowId]);
|
|
32
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.normalizePopupSelectSpec = normalizePopupSelectSpec;
|
|
7
|
+
exports.popupSelect = popupSelect;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const exec_1 = require("../tmux/exec");
|
|
12
|
+
const popupSupport_1 = require("./popupSupport");
|
|
13
|
+
function parseNumber(value) {
|
|
14
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === "string" && value.trim()) {
|
|
18
|
+
const num = Number(value.trim());
|
|
19
|
+
return Number.isFinite(num) ? num : null;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function parseBool(value, defaultValue) {
|
|
24
|
+
if (value === undefined || value === null) {
|
|
25
|
+
return defaultValue;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === "boolean") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
31
|
+
return value !== 0;
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === "string") {
|
|
34
|
+
const v = value.trim().toLowerCase();
|
|
35
|
+
if (["1", "true", "yes", "y", "on"].includes(v)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (["0", "false", "no", "n", "off"].includes(v)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return defaultValue;
|
|
43
|
+
}
|
|
44
|
+
function normalizeTimeout(value, defaultMs) {
|
|
45
|
+
const raw = parseNumber(value);
|
|
46
|
+
const timeoutMs = raw === null ? defaultMs : Math.floor(raw);
|
|
47
|
+
if (timeoutMs <= 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return timeoutMs;
|
|
51
|
+
}
|
|
52
|
+
function normalizeTimeoutFallback(value, fallback) {
|
|
53
|
+
const raw = parseNumber(value);
|
|
54
|
+
if (raw === null) {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
const timeoutMs = Math.floor(raw);
|
|
58
|
+
if (timeoutMs <= 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return timeoutMs;
|
|
62
|
+
}
|
|
63
|
+
function normalizePollInterval(value, defaultMs) {
|
|
64
|
+
const raw = parseNumber(value);
|
|
65
|
+
const interval = Math.floor(raw ?? defaultMs);
|
|
66
|
+
if (!Number.isFinite(interval)) {
|
|
67
|
+
return defaultMs;
|
|
68
|
+
}
|
|
69
|
+
return Math.max(50, Math.min(interval, 2000));
|
|
70
|
+
}
|
|
71
|
+
function parseSize(axis, value) {
|
|
72
|
+
if (value === undefined || value === null || value === "") {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === "number") {
|
|
76
|
+
const n = Math.floor(value);
|
|
77
|
+
return n > 0 ? n : undefined;
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
if (!trimmed) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (trimmed.toLowerCase() === "auto") {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (trimmed.endsWith("%")) {
|
|
88
|
+
const pctRaw = trimmed.slice(0, -1).trim();
|
|
89
|
+
const pct = Number(pctRaw);
|
|
90
|
+
if (!Number.isFinite(pct) || pct <= 0) {
|
|
91
|
+
throw new Error(`invalid ${axis}`);
|
|
92
|
+
}
|
|
93
|
+
return `${pct}%`;
|
|
94
|
+
}
|
|
95
|
+
const n = Number(trimmed);
|
|
96
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
97
|
+
throw new Error(`invalid ${axis}`);
|
|
98
|
+
}
|
|
99
|
+
return Math.floor(n);
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function parseMode(value) {
|
|
104
|
+
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
105
|
+
if (raw === "multi") {
|
|
106
|
+
return "multi";
|
|
107
|
+
}
|
|
108
|
+
return "single";
|
|
109
|
+
}
|
|
110
|
+
function parseOutput(value) {
|
|
111
|
+
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
112
|
+
if (raw === "verbose") {
|
|
113
|
+
return "verbose";
|
|
114
|
+
}
|
|
115
|
+
return "minimal";
|
|
116
|
+
}
|
|
117
|
+
function parseChoices(value) {
|
|
118
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
119
|
+
throw new Error("choices is required");
|
|
120
|
+
}
|
|
121
|
+
const out = [];
|
|
122
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
123
|
+
const raw = value[i];
|
|
124
|
+
if (typeof raw === "string") {
|
|
125
|
+
const label = raw.trim();
|
|
126
|
+
if (!label) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
out.push({ label, value: label, detail: "" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (raw && typeof raw === "object") {
|
|
133
|
+
const label = String(raw.label ?? raw.value ?? "").trim();
|
|
134
|
+
const val = String(raw.value ?? raw.label ?? "").trim();
|
|
135
|
+
const detail = String(raw.detail ?? "").trim();
|
|
136
|
+
if (!label || !val) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
out.push({ label, value: val, detail });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (out.length === 0) {
|
|
144
|
+
throw new Error("choices is empty");
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function normalizePopupSelectSpec(spec) {
|
|
149
|
+
const mode = parseMode(spec.mode);
|
|
150
|
+
const timeoutMs = normalizeTimeout(spec.timeout_ms, 3600000);
|
|
151
|
+
const minSelectedRaw = parseNumber(spec.min_selected);
|
|
152
|
+
const minSelected = Math.max(0, Math.floor(minSelectedRaw ?? (mode === "multi" ? 0 : 1)));
|
|
153
|
+
const maxSelectedRaw = parseNumber(spec.max_selected);
|
|
154
|
+
const maxSelected = maxSelectedRaw === null ? null : Math.max(0, Math.floor(maxSelectedRaw));
|
|
155
|
+
const allowCustomInput = parseBool(spec.allow_custom_input, false);
|
|
156
|
+
const customInputKey = (spec.custom_input_key ?? "ctrl-e").trim() || "ctrl-e";
|
|
157
|
+
return {
|
|
158
|
+
mode,
|
|
159
|
+
title: String(spec.title ?? "Select").trim() || "Select",
|
|
160
|
+
message: String(spec.message ?? "").trim(),
|
|
161
|
+
prompt: String(spec.prompt ?? "> ").trim() || "> ",
|
|
162
|
+
preview: String(spec.preview ?? "").trim(),
|
|
163
|
+
width: parseSize("width", spec.width),
|
|
164
|
+
height: parseSize("height", spec.height),
|
|
165
|
+
timeoutMs,
|
|
166
|
+
waitForFocusTimeoutMs: normalizeTimeoutFallback(spec.wait_for_focus_timeout_ms, timeoutMs),
|
|
167
|
+
waitForResultTimeoutMs: normalizeTimeoutFallback(spec.wait_for_result_timeout_ms, timeoutMs),
|
|
168
|
+
focusPollIntervalMs: normalizePollInterval(spec.focus_poll_interval_ms, 250),
|
|
169
|
+
output: parseOutput(spec.output),
|
|
170
|
+
minSelected,
|
|
171
|
+
maxSelected,
|
|
172
|
+
allowCustomInput,
|
|
173
|
+
customInputKey,
|
|
174
|
+
deferIfUnfocused: parseBool(spec.defer_if_unfocused, true),
|
|
175
|
+
deferUntilPaneActive: parseBool(spec.defer_until_pane_active, true),
|
|
176
|
+
startDelayMs: Math.max(0, Math.floor(parseNumber(spec.start_delay_ms) ?? 0)),
|
|
177
|
+
choices: parseChoices(spec.choices)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function sleep(ms) {
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
182
|
+
}
|
|
183
|
+
function shellQuote(value) {
|
|
184
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
185
|
+
}
|
|
186
|
+
function parseClientFlags(raw) {
|
|
187
|
+
return new Set(raw
|
|
188
|
+
.split(",")
|
|
189
|
+
.map((part) => part.trim())
|
|
190
|
+
.filter(Boolean));
|
|
191
|
+
}
|
|
192
|
+
async function listClients() {
|
|
193
|
+
const fmt = "#{client_name}\t#{client_tty}\t#{client_flags}\t#{session_id}\t#{session_name}\t#{window_id}\t#{pane_id}";
|
|
194
|
+
const result = await (0, exec_1.tmuxExec)(["list-clients", "-F", fmt]);
|
|
195
|
+
const lines = result.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
196
|
+
const clients = [];
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const parts = line.split("\t");
|
|
199
|
+
if (parts.length !== 7) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const [name, tty, flagsRaw, sessionId, sessionName, windowId, paneId] = parts.map((part) => part.trim());
|
|
203
|
+
if (!name) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
clients.push({
|
|
207
|
+
name,
|
|
208
|
+
tty,
|
|
209
|
+
flags: parseClientFlags(flagsRaw),
|
|
210
|
+
sessionId,
|
|
211
|
+
sessionName,
|
|
212
|
+
windowId,
|
|
213
|
+
paneId
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return clients;
|
|
217
|
+
}
|
|
218
|
+
function pickFrontClient(clients) {
|
|
219
|
+
const focused = clients.find((c) => c.flags.has("focused"));
|
|
220
|
+
if (focused) {
|
|
221
|
+
return focused;
|
|
222
|
+
}
|
|
223
|
+
if (clients.length === 1) {
|
|
224
|
+
return clients[0];
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
function pickClientForOrigin(clients, origin, options) {
|
|
229
|
+
const front = pickFrontClient(clients);
|
|
230
|
+
if (options.requirePaneActive) {
|
|
231
|
+
if (front && front.paneId === origin.paneId) {
|
|
232
|
+
return front;
|
|
233
|
+
}
|
|
234
|
+
for (const client of clients) {
|
|
235
|
+
if (client.paneId === origin.paneId) {
|
|
236
|
+
return client;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (front && front.windowId === origin.windowId) {
|
|
242
|
+
return front;
|
|
243
|
+
}
|
|
244
|
+
for (const client of clients) {
|
|
245
|
+
if (client.windowId === origin.windowId) {
|
|
246
|
+
return client;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
async function getPaneWindowId(paneId) {
|
|
252
|
+
const result = await (0, exec_1.tmuxExec)(["display-message", "-p", "-t", paneId, "#{window_id}"]);
|
|
253
|
+
const windowId = result.stdout.trim();
|
|
254
|
+
if (!windowId) {
|
|
255
|
+
throw new Error("failed to resolve origin window");
|
|
256
|
+
}
|
|
257
|
+
return windowId;
|
|
258
|
+
}
|
|
259
|
+
async function resolvePopupClientTarget(origin, spec) {
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
const requirePaneActive = spec.deferUntilPaneActive;
|
|
262
|
+
while (true) {
|
|
263
|
+
const clients = await listClients();
|
|
264
|
+
const candidate = pickClientForOrigin(clients, origin, { requirePaneActive });
|
|
265
|
+
if (candidate) {
|
|
266
|
+
return candidate.name;
|
|
267
|
+
}
|
|
268
|
+
if (!spec.deferIfUnfocused) {
|
|
269
|
+
return pickFrontClient(clients)?.name;
|
|
270
|
+
}
|
|
271
|
+
if (spec.waitForFocusTimeoutMs !== null && Date.now() - started >= spec.waitForFocusTimeoutMs) {
|
|
272
|
+
throw new Error("ui select timed out waiting for focus");
|
|
273
|
+
}
|
|
274
|
+
await sleep(spec.focusPollIntervalMs);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function splitChoiceLine(line) {
|
|
278
|
+
const [label, value, detail] = line.split("\t");
|
|
279
|
+
if (!label || !value) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return { label, value, detail: detail ?? "" };
|
|
283
|
+
}
|
|
284
|
+
function parseFzfOutput(raw, spec) {
|
|
285
|
+
const lines = raw
|
|
286
|
+
.split(/\r?\n/)
|
|
287
|
+
.map((line) => line.trimEnd())
|
|
288
|
+
.filter((line) => line.length > 0);
|
|
289
|
+
if (!spec.allowCustomInput) {
|
|
290
|
+
const values = [];
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
const parsed = splitChoiceLine(line);
|
|
293
|
+
if (!parsed) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
values.push(parsed.value);
|
|
297
|
+
}
|
|
298
|
+
return { selected: values };
|
|
299
|
+
}
|
|
300
|
+
const query = lines[0] ?? "";
|
|
301
|
+
const key = lines[1] ?? "";
|
|
302
|
+
const selectedLines = lines.slice(2);
|
|
303
|
+
if (key && key.toLowerCase() === spec.customInputKey.toLowerCase()) {
|
|
304
|
+
const trimmed = query.trim();
|
|
305
|
+
if (!trimmed) {
|
|
306
|
+
throw new Error("custom input is empty");
|
|
307
|
+
}
|
|
308
|
+
return { selected: [], customInput: trimmed, via: spec.customInputKey };
|
|
309
|
+
}
|
|
310
|
+
const values = [];
|
|
311
|
+
for (const line of selectedLines) {
|
|
312
|
+
const parsed = splitChoiceLine(line);
|
|
313
|
+
if (!parsed) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
values.push(parsed.value);
|
|
317
|
+
}
|
|
318
|
+
return { selected: values, ...(query ? { query } : {}) };
|
|
319
|
+
}
|
|
320
|
+
async function popupSelect(spec) {
|
|
321
|
+
const support = await (0, popupSupport_1.checkPopupSupport)();
|
|
322
|
+
if (!support.ok) {
|
|
323
|
+
throw new Error(support.reason || "popup is not supported");
|
|
324
|
+
}
|
|
325
|
+
const paneId = String(process.env.TMUX_PANE || "").trim();
|
|
326
|
+
if (!paneId) {
|
|
327
|
+
throw new Error("ui select requires tmux client context; run inside tmux");
|
|
328
|
+
}
|
|
329
|
+
const normalized = normalizePopupSelectSpec(spec);
|
|
330
|
+
const originWindowId = await getPaneWindowId(paneId);
|
|
331
|
+
const clientTarget = await resolvePopupClientTarget({ windowId: originWindowId, paneId }, normalized);
|
|
332
|
+
if (normalized.startDelayMs > 0) {
|
|
333
|
+
await sleep(normalized.startDelayMs);
|
|
334
|
+
}
|
|
335
|
+
const tmpDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "agent-tmux-ui-"));
|
|
336
|
+
const inputPath = node_path_1.default.join(tmpDir, "choices.txt");
|
|
337
|
+
const outPath = node_path_1.default.join(tmpDir, "out.txt");
|
|
338
|
+
const codePath = node_path_1.default.join(tmpDir, "exit_code.txt");
|
|
339
|
+
const scriptPath = node_path_1.default.join(tmpDir, "popup.sh");
|
|
340
|
+
try {
|
|
341
|
+
const lines = normalized.choices
|
|
342
|
+
.map((choice) => `${choice.label}\t${choice.value}\t${choice.detail || ""}`)
|
|
343
|
+
.join("\n");
|
|
344
|
+
await node_fs_1.promises.writeFile(inputPath, `${lines}\n`, "utf8");
|
|
345
|
+
const fzfArgs = [];
|
|
346
|
+
fzfArgs.push("--delimiter=\t", "--with-nth=1,3", `--prompt=${normalized.prompt}`);
|
|
347
|
+
if (normalized.mode === "multi") {
|
|
348
|
+
fzfArgs.push("--multi");
|
|
349
|
+
}
|
|
350
|
+
if (normalized.allowCustomInput) {
|
|
351
|
+
fzfArgs.push("--print-query", `--expect=${normalized.customInputKey}`);
|
|
352
|
+
}
|
|
353
|
+
if (normalized.preview) {
|
|
354
|
+
fzfArgs.push(`--preview=${normalized.preview}`);
|
|
355
|
+
}
|
|
356
|
+
const script = `#!/usr/bin/env bash
|
|
357
|
+
set -euo pipefail
|
|
358
|
+
INPUT=${JSON.stringify(inputPath)}
|
|
359
|
+
OUT=${JSON.stringify(outPath)}
|
|
360
|
+
CODE=${JSON.stringify(codePath)}
|
|
361
|
+
set +e
|
|
362
|
+
cat "$INPUT" | fzf ${fzfArgs.map((arg) => JSON.stringify(arg)).join(" ")} > "$OUT"
|
|
363
|
+
EC=$?
|
|
364
|
+
set -e
|
|
365
|
+
printf '%s' "$EC" > "$CODE"
|
|
366
|
+
exit 0
|
|
367
|
+
`;
|
|
368
|
+
await node_fs_1.promises.writeFile(scriptPath, script, { encoding: "utf8", mode: 0o700 });
|
|
369
|
+
const popupArgs = ["display-popup", "-E"];
|
|
370
|
+
if (clientTarget) {
|
|
371
|
+
popupArgs.push("-c", clientTarget);
|
|
372
|
+
}
|
|
373
|
+
popupArgs.push("-t", paneId, "-T", normalized.title);
|
|
374
|
+
if (normalized.width) {
|
|
375
|
+
popupArgs.push("-w", String(normalized.width));
|
|
376
|
+
}
|
|
377
|
+
if (normalized.height) {
|
|
378
|
+
popupArgs.push("-h", String(normalized.height));
|
|
379
|
+
}
|
|
380
|
+
popupArgs.push(`bash ${shellQuote(scriptPath)}`);
|
|
381
|
+
const popupPromise = (0, exec_1.tmuxExec)(popupArgs);
|
|
382
|
+
const timeoutMs = normalized.waitForResultTimeoutMs;
|
|
383
|
+
if (timeoutMs !== null) {
|
|
384
|
+
await Promise.race([
|
|
385
|
+
popupPromise,
|
|
386
|
+
(async () => {
|
|
387
|
+
await sleep(timeoutMs);
|
|
388
|
+
throw new Error("ui select timed out");
|
|
389
|
+
})()
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
await popupPromise;
|
|
394
|
+
}
|
|
395
|
+
const exitCodeRaw = (await node_fs_1.promises.readFile(codePath, "utf8")).trim();
|
|
396
|
+
const exitCode = Number(exitCodeRaw);
|
|
397
|
+
if (!Number.isFinite(exitCode) || exitCode !== 0) {
|
|
398
|
+
throw new Error("ui select cancelled");
|
|
399
|
+
}
|
|
400
|
+
const rawOut = await node_fs_1.promises.readFile(outPath, "utf8");
|
|
401
|
+
const parsed = parseFzfOutput(rawOut, normalized);
|
|
402
|
+
const selectedValues = parsed.selected;
|
|
403
|
+
if (normalized.mode === "single" && selectedValues.length > 1) {
|
|
404
|
+
throw new Error("ui select returned multiple values for single mode");
|
|
405
|
+
}
|
|
406
|
+
if (normalized.mode === "single" && selectedValues.length === 0 && !parsed.customInput) {
|
|
407
|
+
throw new Error("no selection");
|
|
408
|
+
}
|
|
409
|
+
const count = parsed.customInput ? 1 : selectedValues.length;
|
|
410
|
+
if (normalized.mode === "multi") {
|
|
411
|
+
if (count < normalized.minSelected) {
|
|
412
|
+
throw new Error(`min_selected not satisfied: ${count} < ${normalized.minSelected}`);
|
|
413
|
+
}
|
|
414
|
+
if (normalized.maxSelected !== null && count > normalized.maxSelected) {
|
|
415
|
+
throw new Error(`max_selected exceeded: ${count} > ${normalized.maxSelected}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
mode: normalized.mode,
|
|
420
|
+
selectedValues,
|
|
421
|
+
...(parsed.customInput ? { customInput: parsed.customInput, customInputVia: parsed.via } : {})
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
try {
|
|
426
|
+
await node_fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// ignore
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkPopupSupport = checkPopupSupport;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
function hasTmuxEnv() {
|
|
6
|
+
return Boolean(process.env.TMUX && process.env.TMUX_PANE);
|
|
7
|
+
}
|
|
8
|
+
async function runCommand(command, args) {
|
|
9
|
+
return await new Promise((resolve, reject) => {
|
|
10
|
+
const child = (0, node_child_process_1.spawn)(command, args);
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
if (child.stdout) {
|
|
14
|
+
child.stdout.on("data", (data) => {
|
|
15
|
+
stdout += data.toString();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (child.stderr) {
|
|
19
|
+
child.stderr.on("data", (data) => {
|
|
20
|
+
stderr += data.toString();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
child.on("error", (error) => {
|
|
24
|
+
reject(error);
|
|
25
|
+
});
|
|
26
|
+
child.on("close", (code) => {
|
|
27
|
+
const exitCode = code ?? 0;
|
|
28
|
+
if (exitCode !== 0) {
|
|
29
|
+
reject(new Error(stderr.trim() || stdout.trim() || `${command} failed`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
resolve(stdout);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function supportsDisplayPopup() {
|
|
37
|
+
try {
|
|
38
|
+
const output = await runCommand("tmux", ["list-commands"]);
|
|
39
|
+
return output.includes("display-popup");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function hasFzf() {
|
|
46
|
+
try {
|
|
47
|
+
await runCommand("fzf", ["--version"]);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function checkPopupSupport() {
|
|
55
|
+
if (!hasTmuxEnv()) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: "ui select must run inside tmux (missing TMUX env)"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const popupSupported = await supportsDisplayPopup();
|
|
62
|
+
if (!popupSupported) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: "tmux does not support display-popup"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const fzfOk = await hasFzf();
|
|
69
|
+
if (!fzfOk) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "fzf is required for ui select"
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|