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