fix(team-mode): keep tmux visualization in caller window
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -114,7 +114,7 @@ describe("team-layout-tmux", () => {
|
||||
return null
|
||||
}
|
||||
|
||||
return { sessionId: displaySessionId }
|
||||
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(runTmuxCommandMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test("creates detached focus and grid windows and sends attach via send-keys", async () => {
|
||||
test("creates teammate panes in the caller window and sends attach via send-keys", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -162,12 +162,8 @@ describe("team-layout-tmux", () => {
|
||||
|
||||
// then
|
||||
const commands = getCommands()
|
||||
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",
|
||||
])
|
||||
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2)
|
||||
|
||||
const sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
|
||||
const literals = sendKeysCalls.map((args) => args.join(" "))
|
||||
@@ -175,7 +171,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true)
|
||||
})
|
||||
|
||||
test("uses focus main-vertical and grid tiled windows", async () => {
|
||||
test("uses caller window main-vertical layout with caller pane as primary", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -191,14 +187,14 @@ describe("team-layout-tmux", () => {
|
||||
const commands = getCommands()
|
||||
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(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"])
|
||||
expect(selectLayoutArgs).not.toContain("tiled")
|
||||
expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"])
|
||||
expect(result).not.toBeNull()
|
||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
|
||||
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
|
||||
})
|
||||
|
||||
test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => {
|
||||
test("#given 4 or more teammates #when createTeamLayout runs #then it keeps every teammate in the caller window", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = Array.from({ length: 5 }, (_, index) => ({
|
||||
@@ -212,13 +208,11 @@ 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"])
|
||||
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
|
||||
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5)
|
||||
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).not.toContain("tiled")
|
||||
})
|
||||
|
||||
test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => {
|
||||
@@ -333,7 +327,7 @@ describe("team-layout-tmux", () => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
test("#given caller inside tmux #when createTeamLayout runs #then uses the caller window without a new session", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -347,8 +341,8 @@ describe("team-layout-tmux", () => {
|
||||
// then
|
||||
const commands = getCommands()
|
||||
expect(commands.some((args) => args[0] === "new-session")).toBe(false)
|
||||
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)
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(true)
|
||||
})
|
||||
|
||||
test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => {
|
||||
@@ -364,7 +358,7 @@ describe("team-layout-tmux", () => {
|
||||
expect(result?.ownedSession).toBe(false)
|
||||
})
|
||||
|
||||
test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => {
|
||||
test("#given first teammate #when layout runs #then it splits the caller pane horizontally for teammate area", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
|
||||
@@ -375,8 +369,10 @@ describe("team-layout-tmux", () => {
|
||||
// then
|
||||
const commands = getCommands()
|
||||
const splitCalls = commands.filter((args) => args[0] === "split-window")
|
||||
expect(splitCalls).toEqual([])
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
|
||||
expect(splitCalls).toEqual([
|
||||
["split-window", "-t", process.env.TMUX_PANE ?? "", "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", "/tmp/m1"],
|
||||
])
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
})
|
||||
|
||||
test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => {
|
||||
@@ -397,7 +393,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 separate focus and grid pane maps", async () => {
|
||||
test("#given layout created #when createTeamLayout runs #then it records focus panes only", async () => {
|
||||
// given
|
||||
const { createTeamLayout } = await loadLayoutModule()
|
||||
const members = [
|
||||
@@ -412,9 +408,10 @@ describe("team-layout-tmux", () => {
|
||||
const commands = getCommands()
|
||||
expect(result).not.toBeNull()
|
||||
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
|
||||
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(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
|
||||
expect(result?.focusWindowId).toBe("test-session:0")
|
||||
expect(result?.gridWindowId).toBeUndefined()
|
||||
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
|
||||
expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string
|
||||
|
||||
export type TeamLayoutResult = {
|
||||
focusWindowId: string
|
||||
gridWindowId: string
|
||||
gridWindowId?: string
|
||||
focusPanesByMember: Record<string, string>
|
||||
gridPanesByMember: Record<string, string>
|
||||
targetSessionId: string
|
||||
@@ -34,60 +34,63 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
|
||||
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
|
||||
}
|
||||
|
||||
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> {
|
||||
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"])
|
||||
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 []
|
||||
return result.output.trim().split("\n").filter(Boolean)
|
||||
}
|
||||
|
||||
async function createTeamWindow(
|
||||
function selectExistingTeammatePane(teammatePanes: Array<string>, callerPaneId: string): string {
|
||||
return teammatePanes[Math.floor(teammatePanes.length / 2)] ?? teammatePanes[teammatePanes.length - 1] ?? callerPaneId
|
||||
}
|
||||
|
||||
function buildSplitArgs(callerPaneId: string, teammatePanes: Array<string>, member: TeamLayoutMember): Array<string> {
|
||||
if (teammatePanes.length === 0) {
|
||||
return ["split-window", "-t", callerPaneId, "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member)]
|
||||
}
|
||||
|
||||
return [
|
||||
"split-window",
|
||||
"-t",
|
||||
selectExistingTeammatePane(teammatePanes, callerPaneId),
|
||||
teammatePanes.length % 2 === 1 ? "-v" : "-h",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{pane_id}",
|
||||
"-c",
|
||||
getPaneWorkingDirectory(member),
|
||||
]
|
||||
}
|
||||
|
||||
async function createTeamLayoutInCallerWindow(
|
||||
tmuxPath: string,
|
||||
targetSessionId: string,
|
||||
windowName: string,
|
||||
layout: "main-vertical" | "tiled",
|
||||
callerPaneId: string,
|
||||
windowTarget: string,
|
||||
members: Array<TeamLayoutMember>,
|
||||
serverUrl: string,
|
||||
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
|
||||
const [firstMember, ...restMembers] = members
|
||||
if (!firstMember) return null
|
||||
|
||||
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
|
||||
|
||||
const windowId = created.output.trim()
|
||||
const initialPanes = await listPanesInWindow(tmuxPath, windowId)
|
||||
const firstPaneId = initialPanes[0]
|
||||
if (!firstPaneId) return null
|
||||
|
||||
const panesByMember: Record<string, string> = { [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])
|
||||
}
|
||||
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
|
||||
const panesByMember: Record<string, string> = {}
|
||||
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
|
||||
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
|
||||
|
||||
for (const member of members) {
|
||||
const paneId = panesByMember[member.name]
|
||||
if (!paneId) return null
|
||||
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
|
||||
if (!split.success || !split.output) return null
|
||||
|
||||
const paneId = split.output.trim()
|
||||
teammatePanes = [...teammatePanes, paneId]
|
||||
panesByMember[member.name] = paneId
|
||||
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
|
||||
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
|
||||
}
|
||||
|
||||
return { windowId, panesByMember }
|
||||
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
|
||||
if (!layoutResult.success) return null
|
||||
|
||||
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
|
||||
if (!resizeResult.success) return null
|
||||
|
||||
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
|
||||
}
|
||||
|
||||
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
|
||||
@@ -111,27 +114,21 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
||||
}
|
||||
|
||||
const callerSession = await resolveCallerTmuxSession(tmuxPath)
|
||||
const fallbackSessionName = `omo-team-${teamRunId}`
|
||||
const ownedSession = callerSession === null
|
||||
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
|
||||
|
||||
if (ownedSession) {
|
||||
log("falling back to detached team session because caller tmux session could not be resolved", { teamRunId })
|
||||
const created = await runTmuxCommand(tmuxPath, ["new-session", "-d", "-s", fallbackSessionName, "-P", "-F", "#{window_id}"])
|
||||
if (!created.success || !created.output) return null
|
||||
if (!callerSession) {
|
||||
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
|
||||
if (!focus) return null
|
||||
|
||||
return {
|
||||
focusWindowId: focus.windowId,
|
||||
gridWindowId: grid.windowId,
|
||||
focusPanesByMember: focus.panesByMember,
|
||||
gridPanesByMember: grid.panesByMember,
|
||||
targetSessionId,
|
||||
ownedSession,
|
||||
focusWindowId: focus.focusWindowId,
|
||||
gridWindowId: undefined,
|
||||
focusPanesByMember: focus.focusPanesByMember,
|
||||
gridPanesByMember: {},
|
||||
targetSessionId: callerSession.sessionId,
|
||||
ownedSession: false,
|
||||
}
|
||||
} catch (error) {
|
||||
log("tmux visualization unavailable, skipping", { error: String(error) })
|
||||
|
||||
@@ -23,7 +23,7 @@ type TmuxManagerLike = {
|
||||
|
||||
type TeamLayoutResultLike = {
|
||||
focusWindowId: string
|
||||
gridWindowId: string
|
||||
gridWindowId?: string
|
||||
focusPanesByMember: Record<string, string>
|
||||
gridPanesByMember: Record<string, string>
|
||||
targetSessionId: string
|
||||
@@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike {
|
||||
}
|
||||
|
||||
return typeof value.focusWindowId === "string"
|
||||
&& typeof value.gridWindowId === "string"
|
||||
&& (value.gridWindowId === undefined || typeof value.gridWindowId === "string")
|
||||
&& isRecord(value.focusPanesByMember)
|
||||
&& isRecord(value.gridPanesByMember)
|
||||
&& typeof value.targetSessionId === "string"
|
||||
@@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout(
|
||||
targetSessionId,
|
||||
focusWindowId: layoutResult.focusWindowId,
|
||||
gridWindowId: layoutResult.gridWindowId,
|
||||
paneIds: Object.values(layoutResult.focusPanesByMember),
|
||||
},
|
||||
tmuxManager,
|
||||
]))
|
||||
@@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => {
|
||||
await rm(state.tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => {
|
||||
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then teammate panes appear in the caller window and cleanup leaves the session intact", async () => {
|
||||
// given
|
||||
const state = requireLiveTestState()
|
||||
const layoutModule = await loadLayoutModule()
|
||||
const teamRunId = randomUUID()
|
||||
const shortTeamRunId = teamRunId.slice(0, 8)
|
||||
const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`]
|
||||
const initialWindows = await listWindows(state.callerSessionId)
|
||||
const members: TeamLayoutMemberLike[] = [
|
||||
{
|
||||
@@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => {
|
||||
|
||||
// when
|
||||
const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager)
|
||||
const windowsAppeared = await waitForCondition(async () => {
|
||||
const panesAppeared = await waitForCondition(async () => {
|
||||
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
|
||||
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => panes.stdout.split("\n").includes(paneId))
|
||||
})
|
||||
const windowsUnchangedBeforeCleanup = await waitForCondition(async () => {
|
||||
const windows = await listWindows(state.callerSessionId)
|
||||
return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName))
|
||||
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
})
|
||||
|
||||
await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId)
|
||||
const windowsRemoved = await waitForCondition(async () => {
|
||||
const panesRemoved = await waitForCondition(async () => {
|
||||
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
|
||||
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => !panes.stdout.split("\n").includes(paneId))
|
||||
})
|
||||
const windowsUnchangedAfterCleanup = await waitForCondition(async () => {
|
||||
const windows = await listWindows(state.callerSessionId)
|
||||
const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName))
|
||||
const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
return noExpectedWindowsRemain && sameWindowIds
|
||||
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
|
||||
})
|
||||
const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId])
|
||||
|
||||
// then
|
||||
expect(layoutResult.focusWindowId.length).toBeGreaterThan(0)
|
||||
expect(layoutResult.gridWindowId.length).toBeGreaterThan(0)
|
||||
expect(windowsAppeared).toBe(true)
|
||||
expect(windowsRemoved).toBe(true)
|
||||
expect(layoutResult.gridWindowId).toBeUndefined()
|
||||
expect(panesAppeared).toBe(true)
|
||||
expect(windowsUnchangedBeforeCleanup).toBe(true)
|
||||
expect(panesRemoved).toBe(true)
|
||||
expect(windowsUnchangedAfterCleanup).toBe(true)
|
||||
expect(callerSessionStillAlive.success).toBe(true)
|
||||
expect(process.env.TMUX_PANE).toBe(state.callerPaneId)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user