refactor(team-mode): use dedicated focus and grid windows for team layout
Replace in-window pane splitting with two purpose-built windows (focus: main-vertical, grid: tiled) created off the target session, so leader pane is never disturbed and layouts no longer collapse under teammate count. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -12,6 +12,7 @@ let nextWindowNumber = 1
|
|||||||
let nextPaneNumber = 1
|
let nextPaneNumber = 1
|
||||||
let displaySessionId = "$7"
|
let displaySessionId = "$7"
|
||||||
let displaySuccess = true
|
let displaySuccess = true
|
||||||
|
const panesByWindow = new Map<string, string[]>()
|
||||||
|
|
||||||
function createTmuxCommandResult(output: string, success = true) {
|
function createTmuxCommandResult(output: string, success = true) {
|
||||||
return {
|
return {
|
||||||
@@ -23,7 +24,7 @@ function createTmuxCommandResult(output: string, success = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runTmuxCommandMock = mock((_tmuxPath: string, args: Array<string>, _options?: unknown) => {
|
function defaultRunTmuxCommand(_tmuxPath: string, args: Array<string>, _options?: unknown) {
|
||||||
const command = args[0]
|
const command = args[0]
|
||||||
|
|
||||||
if (command === "display" && args.includes("#{session_name}:#{window_index}")) {
|
if (command === "display" && args.includes("#{session_name}:#{window_index}")) {
|
||||||
@@ -43,8 +44,8 @@ const runTmuxCommandMock = mock((_tmuxPath: string, args: Array<string>, _option
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "list-panes") {
|
if (command === "list-panes") {
|
||||||
const allPanes = [process.env.TMUX_PANE ?? "%0"]
|
const windowTarget = args[2] ?? ""
|
||||||
for (let i = 1; i < nextPaneNumber; i++) allPanes.push(`%${i}`)
|
const allPanes = panesByWindow.get(windowTarget) ?? [process.env.TMUX_PANE ?? "%0"]
|
||||||
return Promise.resolve(createTmuxCommandResult(allPanes.join("\n")))
|
return Promise.resolve(createTmuxCommandResult(allPanes.join("\n")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +53,26 @@ const runTmuxCommandMock = mock((_tmuxPath: string, args: Array<string>, _option
|
|||||||
return Promise.resolve(createTmuxCommandResult(`@${nextWindowNumber++}`))
|
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") {
|
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(""))
|
return Promise.resolve(createTmuxCommandResult(""))
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const runTmuxCommandMock = mock(defaultRunTmuxCommand)
|
||||||
|
|
||||||
const isServerRunningMock = mock(async (_serverUrl: string) => true)
|
const isServerRunningMock = mock(async (_serverUrl: string) => true)
|
||||||
|
|
||||||
@@ -86,6 +101,8 @@ describe("team-layout-tmux", () => {
|
|||||||
nextPaneNumber = 1
|
nextPaneNumber = 1
|
||||||
displaySessionId = "$7"
|
displaySessionId = "$7"
|
||||||
displaySuccess = true
|
displaySuccess = true
|
||||||
|
panesByWindow.clear()
|
||||||
|
runTmuxCommandMock.mockImplementation(defaultRunTmuxCommand)
|
||||||
process.env.TMUX = "/tmp/tmux-1"
|
process.env.TMUX = "/tmp/tmux-1"
|
||||||
process.env.TMUX_PANE = "%42"
|
process.env.TMUX_PANE = "%42"
|
||||||
spyOn(tmuxPathResolverModule, "getTmuxPath").mockResolvedValue("tmux")
|
spyOn(tmuxPathResolverModule, "getTmuxPath").mockResolvedValue("tmux")
|
||||||
@@ -132,7 +149,7 @@ describe("team-layout-tmux", () => {
|
|||||||
expect(runTmuxCommandMock).toHaveBeenCalledTimes(0)
|
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
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = [
|
const members = [
|
||||||
@@ -145,9 +162,12 @@ describe("team-layout-tmux", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
const splitCalls = commands.filter((args) => args[0] === "split-window")
|
const newWindowCalls = commands.filter((args) => args[0] === "new-window")
|
||||||
expect(splitCalls.length).toBe(2)
|
expect(newWindowCalls.length).toBe(2)
|
||||||
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
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 sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
|
||||||
const literals = sendKeysCalls.map((args) => args.join(" "))
|
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)
|
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
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = [
|
const members = [
|
||||||
@@ -170,14 +190,15 @@ describe("team-layout-tmux", () => {
|
|||||||
// then
|
// then
|
||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
|
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
|
||||||
expect(selectLayoutArgs.every((l) => l === "main-vertical")).toBe(true)
|
expect(selectLayoutArgs).toContain("main-vertical")
|
||||||
const resizeCalls = commands.filter((args) => args[0] === "resize-pane" && args.includes("30%"))
|
expect(selectLayoutArgs).toContain("tiled")
|
||||||
expect(resizeCalls.length).toBeGreaterThan(0)
|
expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"])
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
|
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
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = Array.from({ length: 5 }, (_, index) => ({
|
const members = Array.from({ length: 5 }, (_, index) => ({
|
||||||
@@ -191,16 +212,13 @@ describe("team-layout-tmux", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
const commands = getCommands()
|
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])
|
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
|
||||||
|
expect(selectLayoutArgs).toContain("main-vertical")
|
||||||
expect(selectLayoutArgs).toContain("tiled")
|
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 () => {
|
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
|
// then
|
||||||
const commands = getCommands()
|
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)
|
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)
|
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createTeamLayout - split-pane topology", () => {
|
describe("createTeamLayout - focus/grid window topology", () => {
|
||||||
test("#given caller inside tmux #when createTeamLayout runs #then splits current window and never creates new windows or sessions", async () => {
|
test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => {
|
||||||
// given
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = [
|
const members = [
|
||||||
@@ -329,8 +347,8 @@ describe("team-layout-tmux", () => {
|
|||||||
// then
|
// then
|
||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
expect(commands.some((args) => args[0] === "new-session")).toBe(false)
|
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] === "new-window").length).toBe(2)
|
||||||
expect(commands.filter((args) => args[0] === "split-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 () => {
|
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)
|
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
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
|
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
|
||||||
@@ -357,9 +375,8 @@ describe("team-layout-tmux", () => {
|
|||||||
// then
|
// then
|
||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
const splitCalls = commands.filter((args) => args[0] === "split-window")
|
const splitCalls = commands.filter((args) => args[0] === "split-window")
|
||||||
expect(splitCalls.length).toBe(1)
|
expect(splitCalls).toEqual([])
|
||||||
expect(splitCalls[0]!.includes("-d")).toBe(true)
|
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
|
||||||
expect(splitCalls[0]!.includes("-P")).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => {
|
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)
|
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
|
// given
|
||||||
const { createTeamLayout } = await loadLayoutModule()
|
const { createTeamLayout } = await loadLayoutModule()
|
||||||
const members = [
|
const members = [
|
||||||
@@ -395,7 +412,9 @@ describe("team-layout-tmux", () => {
|
|||||||
const commands = getCommands()
|
const commands = getCommands()
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
|
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)
|
expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,90 +34,60 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
|
|||||||
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
|
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANE_SHELL_INIT_DELAY_MS = 200
|
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> {
|
||||||
|
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"])
|
||||||
let paneCreationLock: Promise<void> = Promise.resolve()
|
|
||||||
|
|
||||||
async function acquirePaneCreationLock(): Promise<() => void> {
|
|
||||||
let release: () => void
|
|
||||||
const newLock = new Promise<void>((resolve) => { release = resolve })
|
|
||||||
const previousLock = paneCreationLock
|
|
||||||
paneCreationLock = newLock
|
|
||||||
await previousLock
|
|
||||||
return release!
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveCurrentWindowTarget(tmuxPath: string, leaderPaneId: string): Promise<string | null> {
|
|
||||||
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<string | null> {
|
|
||||||
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<Array<string>> {
|
|
||||||
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
|
|
||||||
if (!result.success || !result.output) return []
|
if (!result.success || !result.output) return []
|
||||||
return result.output.trim().split("\n").filter(Boolean)
|
return result.output.trim().split("\n").filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rebalanceWithLeader(tmuxPath: string, windowTarget: string, leaderPaneId: string): Promise<void> {
|
async function createTeamWindow(
|
||||||
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(
|
|
||||||
tmuxPath: string,
|
tmuxPath: string,
|
||||||
leaderPaneId: string,
|
targetSessionId: string,
|
||||||
windowTarget: string,
|
windowName: string,
|
||||||
member: TeamLayoutMember,
|
layout: "main-vertical" | "tiled",
|
||||||
): Promise<string | null> {
|
members: Array<TeamLayoutMember>,
|
||||||
const releaseLock = await acquirePaneCreationLock()
|
serverUrl: string,
|
||||||
try {
|
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
|
||||||
const panes = await listPanesInWindow(tmuxPath, windowTarget)
|
const [firstMember, ...restMembers] = members
|
||||||
const isFirstTeammate = panes.length === 1
|
if (!firstMember) return null
|
||||||
|
|
||||||
let splitResult
|
const created = await runTmuxCommand(tmuxPath, [
|
||||||
if (isFirstTeammate) {
|
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName,
|
||||||
splitResult = await runTmuxCommand(tmuxPath, [
|
"-c", getPaneWorkingDirectory(firstMember),
|
||||||
"split-window", "-t", leaderPaneId, "-h", "-l", "70%", "-d",
|
])
|
||||||
"-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member),
|
if (!created.success || !created.output) return null
|
||||||
])
|
|
||||||
} 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]
|
|
||||||
|
|
||||||
splitResult = await runTmuxCommand(tmuxPath, [
|
const windowId = created.output.trim()
|
||||||
"split-window", "-t", targetPane!, splitVertically ? "-v" : "-h", "-d",
|
const initialPanes = await listPanesInWindow(tmuxPath, windowId)
|
||||||
"-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member),
|
const firstPaneId = initialPanes[0]
|
||||||
])
|
if (!firstPaneId) return null
|
||||||
}
|
|
||||||
|
|
||||||
if (!splitResult.success || !splitResult.output) return null
|
const panesByMember: Record<string, string> = { [firstMember.name]: firstPaneId }
|
||||||
const paneId = splitResult.output.trim()
|
for (const member of restMembers) {
|
||||||
|
const split = await runTmuxCommand(tmuxPath, [
|
||||||
await rebalanceWithLeader(tmuxPath, windowTarget, leaderPaneId)
|
"split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId,
|
||||||
await new Promise((resolve) => setTimeout(resolve, PANE_SHELL_INIT_DELAY_MS))
|
"-c", getPaneWorkingDirectory(member),
|
||||||
|
])
|
||||||
return paneId
|
if (!split.success || !split.output) return null
|
||||||
} finally {
|
panesByMember[member.name] = split.output.trim()
|
||||||
releaseLock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
|
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
|
||||||
@@ -141,9 +111,8 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callerSession = await resolveCallerTmuxSession(tmuxPath)
|
const callerSession = await resolveCallerTmuxSession(tmuxPath)
|
||||||
const leaderPaneId = process.env.TMUX_PANE
|
|
||||||
const fallbackSessionName = `omo-team-${teamRunId}`
|
const fallbackSessionName = `omo-team-${teamRunId}`
|
||||||
const ownedSession = callerSession === null || !leaderPaneId
|
const ownedSession = callerSession === null
|
||||||
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
|
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
|
||||||
|
|
||||||
if (ownedSession) {
|
if (ownedSession) {
|
||||||
@@ -152,36 +121,15 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
|||||||
if (!created.success || !created.output) return null
|
if (!created.success || !created.output) return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!leaderPaneId || ownedSession) {
|
const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl)
|
||||||
log("no leader pane for split layout, skipping visualization", { teamRunId })
|
const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl)
|
||||||
return null
|
if (!focus || !grid) return null
|
||||||
}
|
|
||||||
|
|
||||||
const windowTarget = await resolveCurrentWindowTarget(tmuxPath, leaderPaneId)
|
|
||||||
const windowId = await resolveCurrentWindowId(tmuxPath, leaderPaneId)
|
|
||||||
if (!windowTarget || !windowId) return null
|
|
||||||
|
|
||||||
const panesByMember: Record<string, string> = {}
|
|
||||||
|
|
||||||
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"])
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focusWindowId: windowId,
|
focusWindowId: focus.windowId,
|
||||||
gridWindowId: windowId,
|
gridWindowId: grid.windowId,
|
||||||
focusPanesByMember: panesByMember,
|
focusPanesByMember: focus.panesByMember,
|
||||||
gridPanesByMember: panesByMember,
|
gridPanesByMember: grid.panesByMember,
|
||||||
targetSessionId,
|
targetSessionId,
|
||||||
ownedSession,
|
ownedSession,
|
||||||
}
|
}
|
||||||
@@ -216,7 +164,7 @@ export async function removeTeamLayout(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanupTarget.paneIds && cleanupTarget.paneIds.length > 0) {
|
if (cleanupTarget?.paneIds && cleanupTarget.paneIds.length > 0) {
|
||||||
for (const paneId of cleanupTarget.paneIds) {
|
for (const paneId of cleanupTarget.paneIds) {
|
||||||
try {
|
try {
|
||||||
await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
|
await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
|
||||||
@@ -224,26 +172,11 @@ export async function removeTeamLayout(
|
|||||||
log("tmux team pane cleanup failed", { teamRunId, paneId })
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaderPaneId = process.env.TMUX_PANE
|
|
||||||
const leaderWindowId = leaderPaneId
|
|
||||||
? await resolveCurrentWindowId(tmuxPath, leaderPaneId)
|
|
||||||
: null
|
|
||||||
|
|
||||||
for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) {
|
for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) {
|
||||||
if (!windowId) continue
|
if (!windowId) continue
|
||||||
if (leaderWindowId && windowId === leaderWindowId) {
|
|
||||||
log("tmux team layout skipping kill-window on leader window", { teamRunId, windowId })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
|
await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
|
||||||
} catch (windowError) {
|
} catch (windowError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user