diff --git a/src/features/team-mode/team-layout-tmux/layout.test.ts b/src/features/team-mode/team-layout-tmux/layout.test.ts index 67a9fb0cf..109060f03 100644 --- a/src/features/team-mode/team-layout-tmux/layout.test.ts +++ b/src/features/team-mode/team-layout-tmux/layout.test.ts @@ -12,6 +12,7 @@ let nextWindowNumber = 1 let nextPaneNumber = 1 let displaySessionId = "$7" let displaySuccess = true +const panesByWindow = new Map() function createTmuxCommandResult(output: string, success = true) { return { @@ -23,7 +24,7 @@ function createTmuxCommandResult(output: string, success = true) { } } -const runTmuxCommandMock = mock((_tmuxPath: string, args: Array, _options?: unknown) => { +function defaultRunTmuxCommand(_tmuxPath: string, args: Array, _options?: unknown) { const command = args[0] if (command === "display" && args.includes("#{session_name}:#{window_index}")) { @@ -43,8 +44,8 @@ const runTmuxCommandMock = mock((_tmuxPath: string, args: Array, _option } if (command === "list-panes") { - const allPanes = [process.env.TMUX_PANE ?? "%0"] - for (let i = 1; i < nextPaneNumber; i++) allPanes.push(`%${i}`) + const windowTarget = args[2] ?? "" + const allPanes = panesByWindow.get(windowTarget) ?? [process.env.TMUX_PANE ?? "%0"] return Promise.resolve(createTmuxCommandResult(allPanes.join("\n"))) } @@ -52,12 +53,26 @@ const runTmuxCommandMock = mock((_tmuxPath: string, args: Array, _option return Promise.resolve(createTmuxCommandResult(`@${nextWindowNumber++}`)) } + if (command === "new-window") { + const windowId = `@${nextWindowNumber++}` + panesByWindow.set(windowId, [`%${nextPaneNumber++}`]) + return Promise.resolve(createTmuxCommandResult(windowId)) + } + if (command === "split-window") { - return Promise.resolve(createTmuxCommandResult(`%${nextPaneNumber++}`)) + const paneId = `%${nextPaneNumber++}` + const targetPane = args[args.indexOf("-t") + 1] + const matchedEntry = Array.from(panesByWindow.entries()).find(([, panes]) => panes.includes(targetPane ?? "")) + if (matchedEntry) { + matchedEntry[1].push(paneId) + } + return Promise.resolve(createTmuxCommandResult(paneId)) } return Promise.resolve(createTmuxCommandResult("")) -}) +} + +const runTmuxCommandMock = mock(defaultRunTmuxCommand) const isServerRunningMock = mock(async (_serverUrl: string) => true) @@ -86,6 +101,8 @@ describe("team-layout-tmux", () => { nextPaneNumber = 1 displaySessionId = "$7" displaySuccess = true + panesByWindow.clear() + runTmuxCommandMock.mockImplementation(defaultRunTmuxCommand) process.env.TMUX = "/tmp/tmux-1" process.env.TMUX_PANE = "%42" spyOn(tmuxPathResolverModule, "getTmuxPath").mockResolvedValue("tmux") @@ -132,7 +149,7 @@ describe("team-layout-tmux", () => { expect(runTmuxCommandMock).toHaveBeenCalledTimes(0) }) - test("splits current window for each member and sends attach via send-keys", async () => { + test("creates detached focus and grid windows and sends attach via send-keys", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -145,9 +162,12 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() - const splitCalls = commands.filter((args) => args[0] === "split-window") - expect(splitCalls.length).toBe(2) - expect(commands.some((args) => args[0] === "new-window")).toBe(false) + const newWindowCalls = commands.filter((args) => args[0] === "new-window") + expect(newWindowCalls.length).toBe(2) + expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([ + "team-run-attach-focus", + "team-run-attach-grid", + ]) const sendKeysCalls = commands.filter((args) => args[0] === "send-keys") const literals = sendKeysCalls.map((args) => args.join(" ")) @@ -155,7 +175,7 @@ describe("team-layout-tmux", () => { expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true) }) - test("uses main-vertical layout with leader at 30% for up to 3 teammates", async () => { + test("uses focus main-vertical and grid tiled windows", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -170,14 +190,15 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) - expect(selectLayoutArgs.every((l) => l === "main-vertical")).toBe(true) - const resizeCalls = commands.filter((args) => args[0] === "resize-pane" && args.includes("30%")) - expect(resizeCalls.length).toBeGreaterThan(0) + expect(selectLayoutArgs).toContain("main-vertical") + expect(selectLayoutArgs).toContain("tiled") + expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"]) expect(result).not.toBeNull() expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) + expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"]) }) - test("#given 4 or more teammates #when createTeamLayout runs #then it switches to tiled layout and stops resizing the leader pane", async () => { + test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = Array.from({ length: 5 }, (_, index) => ({ @@ -191,16 +212,13 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() + const newWindowNames = commands + .filter((args) => args[0] === "new-window") + .map((args) => args[args.indexOf("-n") + 1]) + expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"]) const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1]) + expect(selectLayoutArgs).toContain("main-vertical") expect(selectLayoutArgs).toContain("tiled") - const tiledIndex = selectLayoutArgs.indexOf("tiled") - const layoutsAfterTiled = selectLayoutArgs.slice(tiledIndex) - expect(layoutsAfterTiled.every((layout) => layout === "tiled")).toBe(true) - const indexOfFirstTiled = commands.findIndex((args) => args[0] === "select-layout" && args[args.length - 1] === "tiled") - const resizesAfterTiled = commands - .slice(indexOfFirstTiled) - .filter((args) => args[0] === "resize-pane" && args.includes("30%")) - expect(resizesAfterTiled).toEqual([]) }) test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => { @@ -217,7 +235,7 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() - expect(commands.some((args) => args[0] === "select-pane")).toBe(false) + expect(commands.some((args) => args[0] === "select-pane" && !args.includes("-T"))).toBe(false) expect(commands.some((args) => args[0] === "set-option")).toBe(false) }) @@ -314,8 +332,8 @@ describe("team-layout-tmux", () => { expect(commands.some((args) => args[0] === "new-window")).toBe(false) }) - describe("createTeamLayout - split-pane topology", () => { - test("#given caller inside tmux #when createTeamLayout runs #then splits current window and never creates new windows or sessions", async () => { + describe("createTeamLayout - focus/grid window topology", () => { + test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -329,8 +347,8 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() expect(commands.some((args) => args[0] === "new-session")).toBe(false) - expect(commands.some((args) => args[0] === "new-window")).toBe(false) - expect(commands.filter((args) => args[0] === "split-window").length).toBe(2) + expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) + expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(false) }) test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => { @@ -346,7 +364,7 @@ describe("team-layout-tmux", () => { expect(result?.ownedSession).toBe(false) }) - test("#given first teammate #when split-window runs #then it creates a single teammate pane from the current window", async () => { + test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }] @@ -357,9 +375,8 @@ describe("team-layout-tmux", () => { // then const commands = getCommands() const splitCalls = commands.filter((args) => args[0] === "split-window") - expect(splitCalls.length).toBe(1) - expect(splitCalls[0]!.includes("-d")).toBe(true) - expect(splitCalls[0]!.includes("-P")).toBe(true) + expect(splitCalls).toEqual([]) + expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) }) test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => { @@ -380,7 +397,7 @@ describe("team-layout-tmux", () => { expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3) }) - test("#given layout created #when createTeamLayout runs #then it keeps a single current-window split result", async () => { + test("#given layout created #when createTeamLayout runs #then it keeps separate focus and grid pane maps", async () => { // given const { createTeamLayout } = await loadLayoutModule() const members = [ @@ -395,7 +412,9 @@ describe("team-layout-tmux", () => { const commands = getCommands() expect(result).not.toBeNull() expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) - expect(commands.filter((args) => args[0] === "split-window").length).toBe(2) + expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"]) + expect(result?.focusWindowId).not.toBe(result?.gridWindowId) + expect(commands.filter((args) => args[0] === "new-window").length).toBe(2) expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true) }) }) diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts index 7c1d39d86..827f439d4 100644 --- a/src/features/team-mode/team-layout-tmux/layout.ts +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -34,90 +34,60 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}` } -const PANE_SHELL_INIT_DELAY_MS = 200 - -let paneCreationLock: Promise = Promise.resolve() - -async function acquirePaneCreationLock(): Promise<() => void> { - let release: () => void - const newLock = new Promise((resolve) => { release = resolve }) - const previousLock = paneCreationLock - paneCreationLock = newLock - await previousLock - return release! -} - -async function resolveCurrentWindowTarget(tmuxPath: string, leaderPaneId: string): Promise { - const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-t", leaderPaneId, "#{session_name}:#{window_index}"]) - if (!result.success || !result.output) return null - return result.output.trim() -} - -async function resolveCurrentWindowId(tmuxPath: string, leaderPaneId: string): Promise { - const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-t", leaderPaneId, "#{window_id}"]) - if (!result.success || !result.output) return null - return result.output.trim() -} - -async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise> { - const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"]) +async function listPanesInWindow(tmuxPath: string, windowId: string): Promise> { + const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"]) if (!result.success || !result.output) return [] return result.output.trim().split("\n").filter(Boolean) } -async function rebalanceWithLeader(tmuxPath: string, windowTarget: string, leaderPaneId: string): Promise { - const panes = await listPanesInWindow(tmuxPath, windowTarget) - if (panes.length <= 1) return - // main-vertical with many teammates collapses each pane to ~10 rows, which the - // opencode TUI cannot render. Switch to tiled once the column gets too tall. - const teammateCount = panes.length - 1 - const layout = teammateCount >= 4 ? "tiled" : "main-vertical" - await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, layout]) - if (layout === "main-vertical") { - await runTmuxCommand(tmuxPath, ["resize-pane", "-t", leaderPaneId, "-x", "30%"]) - } -} - -async function createTeammatePaneInCurrentWindow( +async function createTeamWindow( tmuxPath: string, - leaderPaneId: string, - windowTarget: string, - member: TeamLayoutMember, -): Promise { - const releaseLock = await acquirePaneCreationLock() - try { - const panes = await listPanesInWindow(tmuxPath, windowTarget) - const isFirstTeammate = panes.length === 1 + targetSessionId: string, + windowName: string, + layout: "main-vertical" | "tiled", + members: Array, + serverUrl: string, +): Promise<{ windowId: string; panesByMember: Record } | null> { + const [firstMember, ...restMembers] = members + if (!firstMember) return null - let splitResult - if (isFirstTeammate) { - splitResult = await runTmuxCommand(tmuxPath, [ - "split-window", "-t", leaderPaneId, "-h", "-l", "70%", "-d", - "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member), - ]) - } else { - const teammatePanes = panes.filter((p) => p !== leaderPaneId) - const teammateCount = teammatePanes.length - const splitVertically = teammateCount % 2 === 1 - const targetIndex = Math.floor((teammateCount - 1) / 2) - const targetPane = teammatePanes[targetIndex] ?? teammatePanes[teammatePanes.length - 1] + const created = await runTmuxCommand(tmuxPath, [ + "new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName, + "-c", getPaneWorkingDirectory(firstMember), + ]) + if (!created.success || !created.output) return null - splitResult = await runTmuxCommand(tmuxPath, [ - "split-window", "-t", targetPane!, splitVertically ? "-v" : "-h", "-d", - "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member), - ]) - } + const windowId = created.output.trim() + const initialPanes = await listPanesInWindow(tmuxPath, windowId) + const firstPaneId = initialPanes[0] + if (!firstPaneId) return null - if (!splitResult.success || !splitResult.output) return null - const paneId = splitResult.output.trim() - - await rebalanceWithLeader(tmuxPath, windowTarget, leaderPaneId) - await new Promise((resolve) => setTimeout(resolve, PANE_SHELL_INIT_DELAY_MS)) - - return paneId - } finally { - releaseLock() + const panesByMember: Record = { [firstMember.name]: firstPaneId } + for (const member of restMembers) { + const split = await runTmuxCommand(tmuxPath, [ + "split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId, + "-c", getPaneWorkingDirectory(member), + ]) + if (!split.success || !split.output) return null + panesByMember[member.name] = split.output.trim() } + + const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout]) + if (!layoutResult.success) return null + + if (layout === "main-vertical") { + await runTmuxCommand(tmuxPath, ["set-window-option", "-t", windowId, "main-pane-width", "60%"]) + await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout]) + } + + for (const member of members) { + const paneId = panesByMember[member.name] + if (!paneId) return null + await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name]) + await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"]) + } + + return { windowId, panesByMember } } export async function createTeamLayout(teamRunId: string, members: Array, tmuxMgr: TmuxSessionManager): Promise { @@ -141,9 +111,8 @@ export async function createTeamLayout(teamRunId: string, members: Array = {} - - for (const member of members) { - const paneId = await createTeammatePaneInCurrentWindow(tmuxPath, leaderPaneId, windowTarget, member) - if (paneId) panesByMember[member.name] = paneId - } - - if (Object.keys(panesByMember).length === 0) return null - - for (const member of members) { - const paneId = panesByMember[member.name] - if (!paneId) continue - const cmd = buildAttachCommand(member, serverUrl) - await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, cmd, "Enter"]) - } + const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl) + const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl) + if (!focus || !grid) return null return { - focusWindowId: windowId, - gridWindowId: windowId, - focusPanesByMember: panesByMember, - gridPanesByMember: panesByMember, + focusWindowId: focus.windowId, + gridWindowId: grid.windowId, + focusPanesByMember: focus.panesByMember, + gridPanesByMember: grid.panesByMember, targetSessionId, ownedSession, } @@ -216,7 +164,7 @@ export async function removeTeamLayout( return } - if (cleanupTarget.paneIds && cleanupTarget.paneIds.length > 0) { + if (cleanupTarget?.paneIds && cleanupTarget.paneIds.length > 0) { for (const paneId of cleanupTarget.paneIds) { try { await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId]) @@ -224,26 +172,11 @@ export async function removeTeamLayout( log("tmux team pane cleanup failed", { teamRunId, paneId }) } } - - const leaderPaneId = process.env.TMUX_PANE - if (leaderPaneId) { - const windowTarget = await resolveCurrentWindowTarget(tmuxPath, leaderPaneId) - if (windowTarget) await rebalanceWithLeader(tmuxPath, windowTarget, leaderPaneId) - } return } - const leaderPaneId = process.env.TMUX_PANE - const leaderWindowId = leaderPaneId - ? await resolveCurrentWindowId(tmuxPath, leaderPaneId) - : null - for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) { if (!windowId) continue - if (leaderWindowId && windowId === leaderWindowId) { - log("tmux team layout skipping kill-window on leader window", { teamRunId, windowId }) - continue - } try { await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId]) } catch (windowError) {