pi-teams 0.8.7 → 0.9.2

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Tmux Adapter Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { TmuxAdapter } from "./tmux-adapter";
7
+ import * as terminalAdapter from "../utils/terminal-adapter";
8
+
9
+ describe("TmuxAdapter", () => {
10
+ let adapter: TmuxAdapter;
11
+ let mockExecCommand: ReturnType<typeof vi.spyOn>;
12
+ const originalTmux = process.env.TMUX;
13
+ const originalTmuxPane = process.env.TMUX_PANE;
14
+
15
+ beforeEach(() => {
16
+ adapter = new TmuxAdapter();
17
+ mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
18
+ process.env.TMUX = "/tmp/tmux-1000/default,123,0";
19
+ process.env.TMUX_PANE = "%16";
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+
25
+ if (originalTmux === undefined) delete process.env.TMUX;
26
+ else process.env.TMUX = originalTmux;
27
+
28
+ if (originalTmuxPane === undefined) delete process.env.TMUX_PANE;
29
+ else process.env.TMUX_PANE = originalTmuxPane;
30
+ });
31
+
32
+ it("should have the correct name", () => {
33
+ expect(adapter.name).toBe("tmux");
34
+ });
35
+
36
+ it("should detect tmux when TMUX is set", () => {
37
+ expect(adapter.detect()).toBe(true);
38
+ });
39
+
40
+ it("should target the originating pane and its window when spawning", () => {
41
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
42
+ if (
43
+ args[0] === "display-message" &&
44
+ args[1] === "-p" &&
45
+ args[2] === "-t" &&
46
+ args[3] === "%16" &&
47
+ args[4] === "#{pane_id}"
48
+ ) {
49
+ return { stdout: "%16", stderr: "", status: 0 };
50
+ }
51
+
52
+ if (args[0] === "split-window") {
53
+ return { stdout: "%42", stderr: "", status: 0 };
54
+ }
55
+
56
+ if (
57
+ args[0] === "display-message" &&
58
+ args[1] === "-p" &&
59
+ args[2] === "-t" &&
60
+ args[3] === "%16" &&
61
+ args[4] === "#{window_id}"
62
+ ) {
63
+ return { stdout: "@7", stderr: "", status: 0 };
64
+ }
65
+
66
+ return { stdout: "", stderr: "", status: 0 };
67
+ });
68
+
69
+ const paneId = adapter.spawn({
70
+ name: "agent-1",
71
+ cwd: "/tmp/project",
72
+ command: "pi",
73
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1", OTHER: "ignored" },
74
+ });
75
+
76
+ expect(paneId).toBe("%42");
77
+ expect(mockExecCommand).toHaveBeenCalledWith(
78
+ "tmux",
79
+ [
80
+ "split-window",
81
+ "-h", "-dP",
82
+ "-F", "#{pane_id}",
83
+ "-t", "%16",
84
+ "-c", "/tmp/project",
85
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
86
+ "sh", "-c", "pi",
87
+ ]
88
+ );
89
+ expect(mockExecCommand).toHaveBeenCalledWith(
90
+ "tmux",
91
+ ["set-window-option", "-t", "@7", "main-pane-width", "60%"]
92
+ );
93
+ expect(mockExecCommand).toHaveBeenCalledWith(
94
+ "tmux",
95
+ ["select-layout", "-t", "@7", "main-vertical"]
96
+ );
97
+ });
98
+
99
+ it("should prefer an explicit anchor pane when spawning", () => {
100
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
101
+ if (
102
+ args[0] === "display-message" &&
103
+ args[1] === "-p" &&
104
+ args[2] === "-t" &&
105
+ args[3] === "%3" &&
106
+ args[4] === "#{pane_id}"
107
+ ) {
108
+ return { stdout: "%3", stderr: "", status: 0 };
109
+ }
110
+
111
+ if (args[0] === "split-window") {
112
+ return { stdout: "%42", stderr: "", status: 0 };
113
+ }
114
+
115
+ if (
116
+ args[0] === "display-message" &&
117
+ args[1] === "-p" &&
118
+ args[2] === "-t" &&
119
+ args[3] === "%3" &&
120
+ args[4] === "#{window_id}"
121
+ ) {
122
+ return { stdout: "@9", stderr: "", status: 0 };
123
+ }
124
+
125
+ return { stdout: "", stderr: "", status: 0 };
126
+ });
127
+
128
+ const paneId = adapter.spawn({
129
+ name: "agent-1",
130
+ cwd: "/tmp/project",
131
+ command: "pi",
132
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1" },
133
+ anchorPaneId: "%3",
134
+ });
135
+
136
+ expect(paneId).toBe("%42");
137
+ expect(mockExecCommand).toHaveBeenCalledWith(
138
+ "tmux",
139
+ [
140
+ "split-window",
141
+ "-h", "-dP",
142
+ "-F", "#{pane_id}",
143
+ "-t", "%3",
144
+ "-c", "/tmp/project",
145
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
146
+ "sh", "-c", "pi",
147
+ ]
148
+ );
149
+ expect(mockExecCommand).toHaveBeenCalledWith(
150
+ "tmux",
151
+ ["set-window-option", "-t", "@9", "main-pane-width", "60%"]
152
+ );
153
+ expect(mockExecCommand).toHaveBeenCalledWith(
154
+ "tmux",
155
+ ["select-layout", "-t", "@9", "main-vertical"]
156
+ );
157
+ });
158
+
159
+ it("should fall back to the current pane when the explicit anchor is stale", () => {
160
+ mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
161
+ if (
162
+ args[0] === "display-message" &&
163
+ args[1] === "-p" &&
164
+ args[2] === "-t" &&
165
+ args[3] === "%3" &&
166
+ args[4] === "#{pane_id}"
167
+ ) {
168
+ return { stdout: "", stderr: "no such pane", status: 1 };
169
+ }
170
+
171
+ if (
172
+ args[0] === "display-message" &&
173
+ args[1] === "-p" &&
174
+ args[2] === "-t" &&
175
+ args[3] === "%16" &&
176
+ args[4] === "#{pane_id}"
177
+ ) {
178
+ return { stdout: "%16", stderr: "", status: 0 };
179
+ }
180
+
181
+ if (args[0] === "split-window") {
182
+ return { stdout: "%42", stderr: "", status: 0 };
183
+ }
184
+
185
+ if (
186
+ args[0] === "display-message" &&
187
+ args[1] === "-p" &&
188
+ args[2] === "-t" &&
189
+ args[3] === "%16" &&
190
+ args[4] === "#{window_id}"
191
+ ) {
192
+ return { stdout: "@7", stderr: "", status: 0 };
193
+ }
194
+
195
+ return { stdout: "", stderr: "", status: 0 };
196
+ });
197
+
198
+ const paneId = adapter.spawn({
199
+ name: "agent-1",
200
+ cwd: "/tmp/project",
201
+ command: "pi",
202
+ env: { PI_TEAM_NAME: "team-1", PI_AGENT_NAME: "agent-1" },
203
+ anchorPaneId: "%3",
204
+ });
205
+
206
+ expect(paneId).toBe("%42");
207
+ expect(mockExecCommand).toHaveBeenCalledWith(
208
+ "tmux",
209
+ [
210
+ "split-window",
211
+ "-h", "-dP",
212
+ "-F", "#{pane_id}",
213
+ "-t", "%16",
214
+ "-c", "/tmp/project",
215
+ "env", "PI_TEAM_NAME=team-1", "PI_AGENT_NAME=agent-1",
216
+ "sh", "-c", "pi",
217
+ ]
218
+ );
219
+ });
220
+
221
+ it("should target the current pane when setting the title", () => {
222
+ mockExecCommand.mockReturnValue({ stdout: "", stderr: "", status: 0 });
223
+
224
+ adapter.setTitle("team: agent-1");
225
+
226
+ expect(mockExecCommand).toHaveBeenCalledWith(
227
+ "tmux",
228
+ ["select-pane", "-t", "%16", "-T", "team: agent-1"]
229
+ );
230
+ });
231
+ });
@@ -15,19 +15,70 @@ export class TmuxAdapter implements TerminalAdapter {
15
15
  return !!process.env.TMUX;
16
16
  }
17
17
 
18
+ private getCurrentPaneId(): string | null {
19
+ const paneId = process.env.TMUX_PANE?.trim();
20
+ return paneId ? paneId : null;
21
+ }
22
+
23
+ private getWindowIdForPane(paneId: string | null | undefined): string | null {
24
+ if (!paneId) return null;
25
+
26
+ try {
27
+ const result = execCommand("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"]);
28
+ if (result.status !== 0) return null;
29
+
30
+ const windowId = result.stdout.trim();
31
+ return windowId || null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ private isPaneUsable(paneId: string | null | undefined): paneId is string {
38
+ if (!paneId) return false;
39
+
40
+ try {
41
+ const result = execCommand("tmux", ["display-message", "-p", "-t", paneId, "#{pane_id}"]);
42
+ return result.status === 0 && result.stdout.trim() === paneId;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ private getOriginPaneId(preferredPaneId?: string | null): string | null {
49
+ if (this.isPaneUsable(preferredPaneId)) {
50
+ return preferredPaneId;
51
+ }
52
+
53
+ const currentPaneId = this.getCurrentPaneId();
54
+ if (this.isPaneUsable(currentPaneId)) {
55
+ return currentPaneId;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
18
61
  spawn(options: SpawnOptions): string {
19
62
  const envArgs = Object.entries(options.env)
20
63
  .filter(([k]) => k.startsWith("PI_"))
21
64
  .map(([k, v]) => `${k}=${v}`);
22
65
 
66
+ const originPaneId = this.getOriginPaneId(options.anchorPaneId);
23
67
  const tmuxArgs = [
24
68
  "split-window",
25
69
  "-h", "-dP",
26
70
  "-F", "#{pane_id}",
71
+ ];
72
+
73
+ if (originPaneId) {
74
+ tmuxArgs.push("-t", originPaneId);
75
+ }
76
+
77
+ tmuxArgs.push(
27
78
  "-c", options.cwd,
28
79
  "env", ...envArgs,
29
80
  "sh", "-c", options.command
30
- ];
81
+ );
31
82
 
32
83
  const result = execCommand("tmux", tmuxArgs);
33
84
 
@@ -35,11 +86,20 @@ export class TmuxAdapter implements TerminalAdapter {
35
86
  throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
36
87
  }
37
88
 
38
- // Apply layout after spawning
39
- execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
40
- execCommand("tmux", ["select-layout", "main-vertical"]);
89
+ const newPaneId = result.stdout.trim();
90
+ const layoutTarget = this.getWindowIdForPane(originPaneId) ?? this.getWindowIdForPane(newPaneId);
91
+
92
+ // Apply layout to the exact window that contains the spawned pane so the
93
+ // split always stays anchored to the intended tmux window.
94
+ if (layoutTarget) {
95
+ execCommand("tmux", ["set-window-option", "-t", layoutTarget, "main-pane-width", "60%"]);
96
+ execCommand("tmux", ["select-layout", "-t", layoutTarget, "main-vertical"]);
97
+ } else {
98
+ execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
99
+ execCommand("tmux", ["select-layout", "main-vertical"]);
100
+ }
41
101
 
42
- return result.stdout.trim();
102
+ return newPaneId;
43
103
  }
44
104
 
45
105
  kill(paneId: string): void {
@@ -69,7 +129,11 @@ export class TmuxAdapter implements TerminalAdapter {
69
129
 
70
130
  setTitle(title: string): void {
71
131
  try {
72
- execCommand("tmux", ["select-pane", "-T", title]);
132
+ const paneId = this.getCurrentPaneId();
133
+ const args = paneId
134
+ ? ["select-pane", "-t", paneId, "-T", title]
135
+ : ["select-pane", "-T", title];
136
+ execCommand("tmux", args);
73
137
  } catch {
74
138
  // Ignore errors
75
139
  }