Skip to content

Commit 5308220

Browse files
authored
fix: isolate pane base index from user tmux config (#115)
* fix: isolate pane base index from user tmux config * fix: scope pane base index to workmux windows
1 parent 6748292 commit 5308220

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

backend/src/__tests__/session-service.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ describe("ensureSessionLayout", () => {
154154
call.startsWith(`createWindow:${plan.sessionName}:${plan.windowName}:/repo/project/__worktrees/feature-search:shell-cmd`),
155155
),
156156
).toBe(true);
157+
expect(tmux.calls).toContain(`setWindowOption:${plan.sessionName}:${plan.windowName}:pane-base-index:0`);
157158
expect(
158159
tmux.calls.some((call) =>
159160
call.startsWith(`splitWindow:${plan.sessionName}:${plan.windowName}.0:right:25:/repo/project/__worktrees/feature-search:shell-cmd`),

backend/src/__tests__/tmux-adapter.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,82 @@
11
import { describe, expect, it } from "bun:test";
2+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import {
36
buildProjectSessionName,
47
buildWorktreeWindowName,
58
parseWindowSummaries,
69
sanitizeTmuxNameSegment,
710
} from "../adapters/tmux";
811

12+
function buildEnv(overrides: Record<string, string>): Record<string, string> {
13+
const env: Record<string, string> = {};
14+
for (const [key, value] of Object.entries(process.env)) {
15+
if (value !== undefined) env[key] = value;
16+
}
17+
return {
18+
...env,
19+
...overrides,
20+
};
21+
}
22+
23+
function run(args: string[], env?: Record<string, string>): void {
24+
const result = Bun.spawnSync(args, { env, stdout: "pipe", stderr: "pipe" });
25+
if (result.exitCode !== 0) {
26+
const stderr = new TextDecoder().decode(result.stderr).trim();
27+
throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);
28+
}
29+
}
30+
31+
function read(args: string[], env?: Record<string, string>): string {
32+
const result = Bun.spawnSync(args, { env, stdout: "pipe", stderr: "pipe" });
33+
if (result.exitCode !== 0) {
34+
const stderr = new TextDecoder().decode(result.stderr).trim();
35+
throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);
36+
}
37+
38+
return new TextDecoder().decode(result.stdout).trim();
39+
}
40+
41+
interface LayoutResult {
42+
globalPaneBaseIndex: string;
43+
relatedPaneIndexes: string[];
44+
unrelatedPaneIndexes: string[];
45+
}
46+
47+
function parseLayoutResult(output: string): LayoutResult {
48+
const value: unknown = JSON.parse(output);
49+
if (!value || typeof value !== "object") {
50+
throw new Error("layout result must be an object");
51+
}
52+
53+
const {
54+
globalPaneBaseIndex,
55+
relatedPaneIndexes,
56+
unrelatedPaneIndexes,
57+
} = value as {
58+
globalPaneBaseIndex?: unknown;
59+
relatedPaneIndexes?: unknown;
60+
unrelatedPaneIndexes?: unknown;
61+
};
62+
63+
if (typeof globalPaneBaseIndex !== "string") {
64+
throw new Error("layout result globalPaneBaseIndex must be a string");
65+
}
66+
if (!Array.isArray(relatedPaneIndexes) || !relatedPaneIndexes.every((entry) => typeof entry === "string")) {
67+
throw new Error("layout result relatedPaneIndexes must be a string array");
68+
}
69+
if (!Array.isArray(unrelatedPaneIndexes) || !unrelatedPaneIndexes.every((entry) => typeof entry === "string")) {
70+
throw new Error("layout result unrelatedPaneIndexes must be a string array");
71+
}
72+
73+
return {
74+
globalPaneBaseIndex,
75+
relatedPaneIndexes,
76+
unrelatedPaneIndexes,
77+
};
78+
}
79+
980
describe("sanitizeTmuxNameSegment", () => {
1081
it("normalizes arbitrary path-like input", () => {
1182
expect(sanitizeTmuxNameSegment("Workmux Web/Desktop")).toBe("workmux-web-desktop");
@@ -55,3 +126,94 @@ describe("parseWindowSummaries", () => {
55126
]);
56127
});
57128
});
129+
130+
describe("ensureSessionLayout", () => {
131+
it("keeps the tmux global default at 1 while forcing the workmux window to 0-based panes", async () => {
132+
const testRoot = await mkdtemp(join(tmpdir(), "webmux-tmux-"));
133+
const homeDir = join(testRoot, "home");
134+
const projectRoot = join(testRoot, "repo");
135+
const worktreePath = join(projectRoot, "__worktrees", "feature-search");
136+
await mkdir(homeDir, { recursive: true });
137+
await mkdir(worktreePath, { recursive: true });
138+
139+
await Bun.write(join(homeDir, ".tmux.conf"), "set -g base-index 1\nsetw -g pane-base-index 1\n");
140+
const env = buildEnv({
141+
HOME: homeDir,
142+
TMUX: "",
143+
TMUX_TMPDIR: testRoot,
144+
});
145+
const runnerPath = join(testRoot, "run-layout.ts");
146+
const tmuxModuleUrl = new URL("../adapters/tmux.ts", import.meta.url).href;
147+
const sessionServiceModuleUrl = new URL("../services/session-service.ts", import.meta.url).href;
148+
149+
await Bun.write(
150+
runnerPath,
151+
[
152+
`import { ensureSessionLayout, planSessionLayout } from ${JSON.stringify(sessionServiceModuleUrl)};`,
153+
`import { buildProjectSessionName, buildWorktreeWindowName, BunTmuxGateway } from ${JSON.stringify(tmuxModuleUrl)};`,
154+
"",
155+
"function run(args: string[]): void {",
156+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
157+
" if (result.exitCode !== 0) {",
158+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
159+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
160+
" }",
161+
"}",
162+
"",
163+
"function read(args: string[]): string {",
164+
' const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" });',
165+
" if (result.exitCode !== 0) {",
166+
" const stderr = new TextDecoder().decode(result.stderr).trim();",
167+
' throw new Error(`${args.join(" ")} failed: ${stderr || `exit ${result.exitCode}`}`);',
168+
" }",
169+
' return new TextDecoder().decode(result.stdout).trim();',
170+
"}",
171+
"",
172+
"const projectRoot = process.argv[2];",
173+
"const worktreePath = process.argv[3];",
174+
'if (!projectRoot || !worktreePath) throw new Error("expected projectRoot and worktreePath");',
175+
"",
176+
"const gateway = new BunTmuxGateway();",
177+
"const plan = planSessionLayout(",
178+
" projectRoot,",
179+
' "feature/search",',
180+
" [",
181+
' { id: "agent", kind: "agent", focus: true },',
182+
' { id: "shell", kind: "shell", split: "right", sizePct: 25 },',
183+
" ],",
184+
" {",
185+
" repoRoot: projectRoot,",
186+
" worktreePath,",
187+
" paneCommands: {",
188+
' agent: "printf agent-started",',
189+
' shell: "sh",',
190+
" },",
191+
" },",
192+
");",
193+
"",
194+
"ensureSessionLayout(gateway, plan);",
195+
'run(["tmux", "new-session", "-d", "-s", "unrelated", "-c", projectRoot]);',
196+
'run(["tmux", "new-window", "-d", "-t", "unrelated", "-n", "plain", "-c", projectRoot]);',
197+
"",
198+
"console.log(JSON.stringify({",
199+
' globalPaneBaseIndex: read(["tmux", "show-options", "-g", "-w", "-v", "pane-base-index"]),',
200+
' relatedPaneIndexes: read(["tmux", "list-panes", "-t", `${buildProjectSessionName(projectRoot)}:${buildWorktreeWindowName("feature/search")}`, "-F", "#{pane_index}"]).split("\\n").filter(Boolean),',
201+
' unrelatedPaneIndexes: read(["tmux", "list-panes", "-t", "unrelated:plain", "-F", "#{pane_index}"]).split("\\n").filter(Boolean),',
202+
"}));",
203+
"",
204+
].join("\n"),
205+
);
206+
207+
try {
208+
const result = parseLayoutResult(read(["bun", runnerPath, projectRoot, worktreePath], env));
209+
expect(result.globalPaneBaseIndex).toBe("1");
210+
expect(result.relatedPaneIndexes).toEqual(["0", "1"]);
211+
expect(result.unrelatedPaneIndexes).toEqual(["1"]);
212+
} finally {
213+
try {
214+
run(["tmux", "kill-server"], env);
215+
} catch {}
216+
await rm(testRoot, { recursive: true, force: true });
217+
}
218+
});
219+
});

backend/src/adapters/tmux.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,6 @@ export class BunTmuxGateway implements TmuxGateway {
100100
if (check.exitCode !== 0) {
101101
assertTmuxOk(["new-session", "-d", "-s", sessionName, "-c", cwd], `create tmux session ${sessionName}`);
102102
}
103-
// Force 0-based pane indices so our code works regardless of the user's
104-
// global pane-base-index setting (commonly set to 1).
105-
assertTmuxOk(["set-option", "-t", sessionName, "pane-base-index", "0"], `set pane-base-index on ${sessionName}`);
106103
}
107104

108105
hasWindow(sessionName: string, windowName: string): boolean {

backend/src/services/session-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export function ensureSessionLayout(
117117
cwd: rootPane.cwd,
118118
command: plan.shellCommand,
119119
});
120+
tmux.setWindowOption(plan.sessionName, plan.windowName, "pane-base-index", "0");
120121
tmux.setWindowOption(plan.sessionName, plan.windowName, "automatic-rename", "off");
121122
tmux.setWindowOption(plan.sessionName, plan.windowName, "allow-rename", "off");
122123

0 commit comments

Comments
 (0)