tmux-ide 1.2.1 → 2.0.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,282 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { resolve } from "node:path";
4
+
5
+ const INTERVAL = 1000;
6
+ const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒|/\\-] /;
7
+
8
+ interface MonitorPane {
9
+ id: string;
10
+ pid: string;
11
+ cmd?: string;
12
+ title?: string;
13
+ role?: string;
14
+ type?: string;
15
+ name?: string;
16
+ }
17
+
18
+ // --- Port detection (pure helpers) ---
19
+
20
+ function getListeningPids(): Set<string> {
21
+ // Returns Set of PIDs that have a listening TCP port in range 1024-20000
22
+ try {
23
+ const raw = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPn"], {
24
+ encoding: "utf-8",
25
+ stdio: ["ignore", "pipe", "ignore"],
26
+ });
27
+ const pids = new Set<string>();
28
+ let currentPid: string | null = null;
29
+ for (const line of raw.split("\n")) {
30
+ if (line.startsWith("p")) {
31
+ currentPid = line.slice(1);
32
+ } else if (line.startsWith("n") && currentPid) {
33
+ const match = line.match(/:(\d+)$/);
34
+ if (match) {
35
+ const port = parseInt(match[1]!, 10);
36
+ if (port >= 1024 && port <= 20000) pids.add(currentPid);
37
+ }
38
+ }
39
+ }
40
+ return pids;
41
+ } catch {
42
+ return new Set<string>();
43
+ }
44
+ }
45
+
46
+ function getProcessTree(): Map<string, string> {
47
+ // Returns Map<pid, ppid>
48
+ try {
49
+ const raw = execFileSync("ps", ["-axo", "pid=,ppid="], {
50
+ encoding: "utf-8",
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ });
53
+ const tree = new Map<string, string>();
54
+ for (const line of raw.trim().split("\n")) {
55
+ const parts = line.trim().split(/\s+/);
56
+ if (parts.length === 2) tree.set(parts[0]!, parts[1]!);
57
+ }
58
+ return tree;
59
+ } catch {
60
+ return new Map<string, string>();
61
+ }
62
+ }
63
+
64
+ export function computePortPanes(
65
+ panes: MonitorPane[],
66
+ { listeners, tree }: { listeners?: Set<string>; tree?: Map<string, string> } = {},
67
+ ): Set<string> {
68
+ // Walk up from each listening PID to find which pane owns it
69
+ const resolvedListeners = listeners ?? getListeningPids();
70
+ const resolvedTree = tree ?? getProcessTree();
71
+ if (resolvedListeners.size === 0) return new Set<string>();
72
+
73
+ const panePids = new Map(panes.map((p) => [p.pid, p.id]));
74
+ const result = new Set<string>();
75
+
76
+ for (const listenerPid of resolvedListeners) {
77
+ let pid: string | undefined = listenerPid;
78
+ while (pid && pid !== "0") {
79
+ if (panePids.has(pid)) {
80
+ result.add(panePids.get(pid)!);
81
+ break;
82
+ }
83
+ pid = resolvedTree.get(pid);
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+
89
+ // --- Agent detection ---
90
+
91
+ export function computeAgentStates(panes: MonitorPane[]): Map<string, "busy" | "idle" | null> {
92
+ // Returns Map<paneId, "busy" | "idle" | null>
93
+ const states = new Map<string, "busy" | "idle" | null>();
94
+ for (const pane of panes) {
95
+ const role = pane.role ?? "";
96
+
97
+ // Primary: use @ide_role pane option if available
98
+ if (role === "lead" || role === "teammate") {
99
+ states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
100
+ continue;
101
+ }
102
+
103
+ // Fallback: command-based detection for pre-upgrade sessions
104
+ const cmd = (pane.cmd ?? "").toLowerCase();
105
+ if (!cmd.includes("claude") && !cmd.includes("codex")) {
106
+ states.set(pane.id, null);
107
+ continue;
108
+ }
109
+ states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
110
+ }
111
+ return states;
112
+ }
113
+
114
+ // --- Main loop (only runs when executed directly) ---
115
+
116
+ const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
117
+
118
+ if (isMainModule) {
119
+ const session: string = process.argv[2]!;
120
+ if (!session) process.exit(1);
121
+
122
+ function tmux(...args: string[]): string {
123
+ return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
124
+ }
125
+
126
+ function tmuxSilent(...args: string[]): string {
127
+ try {
128
+ return tmux(...args);
129
+ } catch {
130
+ return "";
131
+ }
132
+ }
133
+
134
+ function sessionExists(): boolean {
135
+ try {
136
+ tmux("has-session", "-t", session);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function hasClients(): boolean {
144
+ return tmuxSilent("list-clients").length > 0;
145
+ }
146
+
147
+ function listPanes(): MonitorPane[] {
148
+ const raw = tmuxSilent(
149
+ "list-panes",
150
+ "-t",
151
+ session,
152
+ "-F",
153
+ "#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{@ide_role}\t#{@ide_type}\t#{@ide_name}",
154
+ );
155
+ if (!raw) return [];
156
+ return raw.split("\n").map((line) => {
157
+ const [id, pid, cmd, title, role, type, name] = line.split("\t");
158
+ return {
159
+ id: id!,
160
+ pid: pid!,
161
+ cmd,
162
+ title,
163
+ role: role || undefined,
164
+ type: type || undefined,
165
+ name: name || undefined,
166
+ };
167
+ });
168
+ }
169
+
170
+ let lastState = "";
171
+
172
+ function tick(): void {
173
+ if (!sessionExists()) process.exit(0);
174
+ if (!hasClients()) return; // skip when nobody is watching
175
+
176
+ const panes = listPanes();
177
+ if (panes.length === 0) return;
178
+
179
+ const portPanes = computePortPanes(panes);
180
+ const agentStates = computeAgentStates(panes);
181
+
182
+ // Build state fingerprint for change detection (includes title drift)
183
+ const stateKey = panes
184
+ .map((p) => {
185
+ const port = portPanes.has(p.id) ? "1" : "0";
186
+ const agent = agentStates.get(p.id) ?? "-";
187
+ const titleDrift = p.name && p.title !== p.name ? "d" : "ok";
188
+ return `${p.id}:${port}:${agent}:${titleDrift}`;
189
+ })
190
+ .join("|");
191
+
192
+ if (stateKey === lastState) return;
193
+
194
+ // Apply changes
195
+ for (const pane of panes) {
196
+ const hasPort = portPanes.has(pane.id) ? "1" : "0";
197
+ const agent = agentStates.get(pane.id);
198
+
199
+ tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
200
+ tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
201
+ tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
202
+
203
+ // Restore configured pane title if Claude Code changed it
204
+ if (pane.name && pane.title !== pane.name) {
205
+ tmuxSilent("select-pane", "-t", pane.id, "-T", pane.name);
206
+ }
207
+ }
208
+
209
+ tmuxSilent("refresh-client", "-S");
210
+ lastState = stateKey;
211
+ }
212
+
213
+ const monitorInterval = setInterval(tick, INTERVAL);
214
+ tick(); // run immediately
215
+
216
+ // Unified shutdown handler — cleans up everything on SIGTERM/SIGINT
217
+ let stopOrchestrator: (() => void) | null = null;
218
+
219
+ function shutdown() {
220
+ clearInterval(monitorInterval);
221
+ if (stopOrchestrator) stopOrchestrator();
222
+ process.exit(0);
223
+ }
224
+
225
+ process.on("SIGTERM", shutdown);
226
+ process.on("SIGINT", shutdown);
227
+
228
+ // Start orchestrator if enabled in config
229
+ (async () => {
230
+ try {
231
+ const { readConfig } = await import("./yaml-io.ts");
232
+ const { config } = readConfig(process.cwd());
233
+
234
+ if (config.orchestrator?.enabled) {
235
+ try {
236
+ const { createOrchestrator } = await import("./orchestrator.ts");
237
+
238
+ // Configure webhooks for event delivery
239
+ if (config.orchestrator.webhooks?.length) {
240
+ const { setWebhookConfig } = await import("./event-log.ts");
241
+ setWebhookConfig(config.orchestrator.webhooks);
242
+ }
243
+
244
+ const orch = config.orchestrator;
245
+
246
+ // Build pane specialty map from ide.yml config
247
+ const paneSpecialties = new Map<string, string[]>();
248
+ for (const row of config.rows) {
249
+ for (const pane of row.panes) {
250
+ if (pane.specialty && pane.title) {
251
+ paneSpecialties.set(
252
+ pane.title,
253
+ pane.specialty.split(",").map((s: string) => s.trim().toLowerCase()),
254
+ );
255
+ }
256
+ }
257
+ }
258
+
259
+ stopOrchestrator = createOrchestrator({
260
+ session,
261
+ dir: process.cwd(),
262
+ autoDispatch: orch.auto_dispatch ?? true,
263
+ stallTimeout: orch.stall_timeout ?? 300000,
264
+ pollInterval: orch.poll_interval ?? 5000,
265
+ masterPane: orch.master_pane ?? null,
266
+ beforeRun: orch.before_run ?? null,
267
+ afterRun: orch.after_run ?? null,
268
+ maxConcurrentAgents: orch.max_concurrent_agents ?? 10,
269
+ dispatchMode: orch.dispatch_mode ?? "tasks",
270
+ paneSpecialties,
271
+ services: orch.services ?? {},
272
+ research: orch.research,
273
+ });
274
+ } catch {
275
+ // Orchestrator module not available yet
276
+ }
277
+ }
278
+ } catch {
279
+ // Config not readable — skip orchestrator
280
+ }
281
+ })();
282
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ buildSessionOptions,
4
+ themeOptions,
5
+ borderOptions,
6
+ behaviorOptions,
7
+ statusBarOptions,
8
+ keyBindings,
9
+ } from "./session-options.ts";
10
+
11
+ describe("buildSessionOptions", () => {
12
+ it("returns an array of command arrays", () => {
13
+ const options = buildSessionOptions("my-session");
14
+
15
+ expect(Array.isArray(options)).toBeTruthy();
16
+ expect(options.length > 0).toBeTruthy();
17
+ for (const cmd of options) {
18
+ expect(Array.isArray(cmd)).toBeTruthy();
19
+ expect(cmd.length >= 2).toBeTruthy();
20
+ }
21
+ });
22
+
23
+ it("composes all sub-builders", () => {
24
+ const session = "test";
25
+ const theme = {};
26
+ const all = buildSessionOptions(session, { theme });
27
+ const expected = [
28
+ ...themeOptions(session, theme),
29
+ ...borderOptions(session, theme),
30
+ ...behaviorOptions(session),
31
+ ...statusBarOptions(session, theme),
32
+ ...keyBindings(),
33
+ ];
34
+
35
+ expect(all).toEqual(expected);
36
+ });
37
+ });
38
+
39
+ describe("themeOptions", () => {
40
+ it("includes expected color settings", () => {
41
+ const opts = themeOptions("my-session", {});
42
+
43
+ const optionNames = opts.map((o) => o[3]);
44
+ expect(optionNames.includes("status-style")).toBeTruthy();
45
+ expect(optionNames.includes("pane-border-style")).toBeTruthy();
46
+ expect(optionNames.includes("pane-active-border-style")).toBeTruthy();
47
+ });
48
+
49
+ it("uses default colors when no theme provided", () => {
50
+ const opts = themeOptions("s", {});
51
+
52
+ expect(opts[0]).toEqual(["set-option", "-t", "s", "status-style", "bg=colour235,fg=colour248"]);
53
+ expect(opts[1]).toEqual(["set-option", "-t", "s", "pane-border-style", "fg=colour238"]);
54
+ expect(opts[2]).toEqual(["set-option", "-t", "s", "pane-active-border-style", "fg=colour75"]);
55
+ });
56
+
57
+ it("respects custom theme overrides", () => {
58
+ const opts = themeOptions("my-session", { accent: "red", border: "blue" });
59
+
60
+ expect(opts[1]).toEqual(["set-option", "-t", "my-session", "pane-border-style", "fg=blue"]);
61
+ expect(opts[2]).toEqual([
62
+ "set-option",
63
+ "-t",
64
+ "my-session",
65
+ "pane-active-border-style",
66
+ "fg=red",
67
+ ]);
68
+ });
69
+ });
70
+
71
+ describe("borderOptions", () => {
72
+ it("includes pane-border-status and pane-border-format", () => {
73
+ const opts = borderOptions("my-session", {});
74
+
75
+ expect(opts[0]).toEqual(["set-option", "-t", "my-session", "pane-border-status", "top"]);
76
+ expect(opts[1][3]).toBe("pane-border-format");
77
+ expect(opts[1][4].includes("pane_current_path")).toBeTruthy();
78
+ });
79
+ });
80
+
81
+ describe("behaviorOptions", () => {
82
+ it("includes mouse, escape-time, and status-interval", () => {
83
+ const opts = behaviorOptions("test-session");
84
+
85
+ const mouseOpt = opts.find((o) => o[3] === "mouse");
86
+ expect(mouseOpt).toEqual(["set-option", "-t", "test-session", "mouse", "on"]);
87
+
88
+ const escapeOpt = opts.find((o) => o[3] === "escape-time");
89
+ expect(escapeOpt).toEqual(["set-option", "-t", "test-session", "escape-time", "0"]);
90
+
91
+ const intervalOpt = opts.find((o) => o[3] === "status-interval");
92
+ expect(intervalOpt).toEqual(["set-option", "-t", "test-session", "status-interval", "1"]);
93
+ });
94
+ });
95
+
96
+ describe("statusBarOptions", () => {
97
+ it("includes two-line status mode", () => {
98
+ const opts = statusBarOptions("my-session", {});
99
+ const statusOpt = opts.find((o) => o[3] === "status");
100
+ expect(statusOpt).toEqual(["set-option", "-t", "my-session", "status", "2"]);
101
+ });
102
+
103
+ it("includes status-format[1] with pane tabs", () => {
104
+ const opts = statusBarOptions("my-session", {});
105
+ const formatOpt = opts.find((o) => o[3] === "status-format[1]");
106
+ expect(formatOpt).toBeTruthy();
107
+ expect(formatOpt[4].includes("#{P:")).toBeTruthy();
108
+ expect(formatOpt[4].includes("pane_id")).toBeTruthy();
109
+ });
110
+
111
+ it("includes status-left with session name", () => {
112
+ const opts = statusBarOptions("my-session", {});
113
+ const leftOpt = opts.find((o) => o[3] === "status-left");
114
+ expect(leftOpt[4].includes("MY-SESSION IDE")).toBeTruthy();
115
+ });
116
+
117
+ it("includes all expected status bar settings", () => {
118
+ const opts = statusBarOptions("s", {});
119
+ const names = opts.map((o) => o[3]);
120
+
121
+ expect(names.includes("status-left")).toBeTruthy();
122
+ expect(names.includes("status-left-length")).toBeTruthy();
123
+ expect(names.includes("status-right")).toBeTruthy();
124
+ expect(names.includes("status-justify")).toBeTruthy();
125
+ expect(names.includes("window-status-current-format")).toBeTruthy();
126
+ expect(names.includes("window-status-format")).toBeTruthy();
127
+ expect(names.includes("status")).toBeTruthy();
128
+ expect(names.includes("status-format[1]")).toBeTruthy();
129
+ });
130
+ });
131
+
132
+ describe("keyBindings", () => {
133
+ it("includes the mouse click binding", () => {
134
+ const bindings = keyBindings();
135
+
136
+ expect(bindings.length).toBe(1);
137
+ expect(bindings[0]).toEqual([
138
+ "bind-key",
139
+ "-n",
140
+ "MouseDown1StatusDefault",
141
+ "select-pane",
142
+ "-t",
143
+ "=",
144
+ ]);
145
+ });
146
+ });
@@ -3,7 +3,12 @@
3
3
  * Each returns an array of tmux command arrays.
4
4
  */
5
5
 
6
- export function buildSessionOptions(session, { theme = {} } = {}) {
6
+ import type { ThemeConfig, TmuxCommand } from "../types.ts";
7
+
8
+ export function buildSessionOptions(
9
+ session: string,
10
+ { theme = {} }: { theme?: ThemeConfig } = {},
11
+ ): TmuxCommand[] {
7
12
  return [
8
13
  ...themeOptions(session, theme),
9
14
  ...borderOptions(session, theme),
@@ -13,7 +18,7 @@ export function buildSessionOptions(session, { theme = {} } = {}) {
13
18
  ];
14
19
  }
15
20
 
16
- export function themeOptions(session, theme) {
21
+ export function themeOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
17
22
  const accent = theme.accent ?? "colour75";
18
23
  const border = theme.border ?? "colour238";
19
24
  const bg = theme.bg ?? "colour235";
@@ -26,7 +31,7 @@ export function themeOptions(session, theme) {
26
31
  ];
27
32
  }
28
33
 
29
- export function borderOptions(session, theme) {
34
+ export function borderOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
30
35
  const accent = theme.accent ?? "colour75";
31
36
  const border = theme.border ?? "colour238";
32
37
  const fg = theme.fg ?? "colour248";
@@ -43,7 +48,7 @@ export function borderOptions(session, theme) {
43
48
  ];
44
49
  }
45
50
 
46
- export function behaviorOptions(session) {
51
+ export function behaviorOptions(session: string): TmuxCommand[] {
47
52
  return [
48
53
  ["set-option", "-t", session, "mouse", "on"],
49
54
  ["set-option", "-t", session, "escape-time", "0"],
@@ -51,7 +56,7 @@ export function behaviorOptions(session) {
51
56
  ];
52
57
  }
53
58
 
54
- export function statusBarOptions(session, theme) {
59
+ export function statusBarOptions(session: string, theme: ThemeConfig): TmuxCommand[] {
55
60
  const accent = theme.accent ?? "colour75";
56
61
  const border = theme.border ?? "colour238";
57
62
  const fg = theme.fg ?? "colour248";
@@ -90,6 +95,6 @@ export function statusBarOptions(session, theme) {
90
95
  ];
91
96
  }
92
97
 
93
- export function keyBindings() {
98
+ export function keyBindings(): TmuxCommand[] {
94
99
  return [["bind-key", "-n", "MouseDown1StatusDefault", "select-pane", "-t", "="]];
95
100
  }
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { shellEscape } from "./shell.ts";
3
+
4
+ describe("shellEscape", () => {
5
+ it("wraps plain strings in single quotes", () => {
6
+ expect(shellEscape("hello")).toBe("'hello'");
7
+ });
8
+
9
+ it("escapes embedded single quotes", () => {
10
+ expect(shellEscape("don't")).toBe("'don'\\''t'");
11
+ });
12
+
13
+ it("handles empty string", () => {
14
+ expect(shellEscape("")).toBe("''");
15
+ });
16
+
17
+ it("handles newlines and shell metacharacters literally inside quotes", () => {
18
+ expect(shellEscape("a; rm -rf /")).toBe("'a; rm -rf /'");
19
+ });
20
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Quote a string for safe embedding in POSIX sh/bash commands as a single word.
3
+ * Wraps in single quotes; embedded `'` becomes `'\''` (end quote, literal quote, resume).
4
+ */
5
+ export function shellEscape(s: string): string {
6
+ // Each "'" in the input becomes '\'' outside a single-quoted string.
7
+ return "'" + s.replace(/'/g, "'\\''") + "'";
8
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { computeSizes, toSplitPercents } from "./sizes.ts";
3
+
4
+ describe("computeSizes", () => {
5
+ it("distributes remaining space to items without size", () => {
6
+ const result = computeSizes([{ size: "70%" }, {}]);
7
+ expect(result).toEqual([70, 30]);
8
+ });
9
+
10
+ it("passes through all explicit sizes", () => {
11
+ const result = computeSizes([{ size: "60%" }, { size: "40%" }]);
12
+ expect(result).toEqual([60, 40]);
13
+ });
14
+
15
+ it("splits equally when no sizes specified", () => {
16
+ const result = computeSizes([{}, {}, {}]);
17
+ const expected = [100 / 3, 100 / 3, 100 / 3];
18
+ expect(result).toEqual(expected);
19
+ });
20
+
21
+ it("handles 3 items with mixed sizes", () => {
22
+ const result = computeSizes([{ size: "60%" }, { size: "25%" }, {}]);
23
+ expect(result).toEqual([60, 25, 15]);
24
+ });
25
+
26
+ it("handles single item without size", () => {
27
+ const result = computeSizes([{}]);
28
+ expect(result).toEqual([100]);
29
+ });
30
+
31
+ it("handles single item with size", () => {
32
+ const result = computeSizes([{ size: "50%" }]);
33
+ expect(result).toEqual([50]);
34
+ });
35
+
36
+ it("handles empty array", () => {
37
+ const result = computeSizes([]);
38
+ expect(result).toEqual([]);
39
+ });
40
+
41
+ it("clamps remaining to zero when sizes exceed 100", () => {
42
+ const result = computeSizes([{ size: "80%" }, { size: "30%" }, {}]);
43
+ expect(result).toEqual([80, 30, 0]);
44
+ });
45
+ });
46
+
47
+ describe("toSplitPercents", () => {
48
+ it("converts 70/30 to [30]", () => {
49
+ expect(toSplitPercents([70, 30])).toEqual([30]);
50
+ });
51
+
52
+ it("converts 60/25/15 correctly", () => {
53
+ const result = toSplitPercents([60, 25, 15]);
54
+ expect(result).toEqual([40, 38]);
55
+ });
56
+
57
+ it("converts equal 50/50 to [50]", () => {
58
+ expect(toSplitPercents([50, 50])).toEqual([50]);
59
+ });
60
+
61
+ it("converts equal thirds", () => {
62
+ const result = toSplitPercents([100 / 3, 100 / 3, 100 / 3]);
63
+ expect(result).toEqual([67, 50]);
64
+ });
65
+
66
+ it("returns empty array for single item", () => {
67
+ expect(toSplitPercents([100])).toEqual([]);
68
+ });
69
+
70
+ it("returns empty array for empty input", () => {
71
+ expect(toSplitPercents([])).toEqual([]);
72
+ });
73
+ });
@@ -3,7 +3,7 @@
3
3
  * Items with `size` (e.g. "70%") keep their value; remaining space is split equally
4
4
  * among items without a size.
5
5
  */
6
- export function computeSizes(items) {
6
+ export function computeSizes(items: { size?: string }[]): number[] {
7
7
  let claimed = 0;
8
8
  let unclaimed = 0;
9
9
  for (const item of items) {
@@ -25,11 +25,11 @@ export function computeSizes(items) {
25
25
  * Each tmux split divides the current pane. The percentage given to -p is
26
26
  * the portion allocated to the NEW (bottom/right) pane.
27
27
  */
28
- export function toSplitPercents(sizes) {
29
- const percents = [];
28
+ export function toSplitPercents(sizes: number[]): number[] {
29
+ const percents: number[] = [];
30
30
  for (let i = 1; i < sizes.length; i++) {
31
31
  const remaining = sizes.slice(i - 1).reduce((a, b) => a + b, 0);
32
- const topShare = sizes[i - 1];
32
+ const topShare = sizes[i - 1]!;
33
33
  percents.push(Math.round(((remaining - topShare) / remaining) * 100));
34
34
  }
35
35
  return percents;