feat(team-mode): add team-layout-tmux for focus+grid pane visualization
Introduce a dedicated module that builds the two-window tmux layout team-mode relies on: - "focus" window uses main-vertical for the lead-centric view - "grid" window uses tiled so every member pane is visible at once createTeamLayout spawns omo-team-<teamRunId>, registers pane titles with color-coded labels, and returns the focus/grid window IDs plus a pane-by-member map. removeTeamLayout tears the session down idempotently. canVisualize short-circuits when TMUX is unset so callers degrade gracefully outside a tmux context. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export { canVisualize, createTeamLayout, removeTeamLayout } from "./layout"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
const spawnMock = mock(() => ({
|
||||||
|
exited: Promise.resolve(0),
|
||||||
|
stdout: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("%1\n")); controller.close() } }),
|
||||||
|
stderr: new ReadableStream({ start(controller) { controller.close() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("bun", () => ({ spawn: spawnMock }))
|
||||||
|
|
||||||
|
mock.module("../../../tools/interactive-bash/tmux-path-resolver", () => ({ getTmuxPath: mock(() => Promise.resolve("tmux")) }))
|
||||||
|
|
||||||
|
mock.module("../../../shared", () => ({ log: mock(() => undefined) }))
|
||||||
|
|
||||||
|
import { createTeamLayout, removeTeamLayout, canVisualize } from "./layout"
|
||||||
|
|
||||||
|
describe("team-layout-tmux", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spawnMock.mockClear()
|
||||||
|
process.env.TMUX = "/tmp/tmux-1"
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null and makes no tmux calls when visualization unavailable", async () => {
|
||||||
|
// given
|
||||||
|
delete process.env.TMUX
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createTeamLayout("run-1", [], {} as never)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(canVisualize()).toBe(false)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(spawnMock).toHaveBeenCalledTimes(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates focus and grid windows", async () => {
|
||||||
|
// given
|
||||||
|
const members = [
|
||||||
|
{ name: "lead", sessionId: "s1", color: "red" },
|
||||||
|
{ name: "m2", sessionId: "s2" },
|
||||||
|
{ name: "m3", sessionId: "s3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
await createTeamLayout("run-2", members, {} as never)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("new-session")
|
||||||
|
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("new-window")
|
||||||
|
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("split-window")
|
||||||
|
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("select-layout")
|
||||||
|
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("select-pane")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns null when tmux command fails", async () => {
|
||||||
|
// given
|
||||||
|
spawnMock.mockImplementationOnce(() => ({
|
||||||
|
exited: Promise.resolve(1),
|
||||||
|
stdout: new ReadableStream({ start(controller) { controller.close() } }),
|
||||||
|
stderr: new ReadableStream({ start(controller) { controller.close() } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await createTeamLayout("run-3", [{ name: "lead", sessionId: "s1" }], {} as never)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleans up the tmux session", async () => {
|
||||||
|
// given
|
||||||
|
// when
|
||||||
|
await removeTeamLayout("run-4", {} as never)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(spawnMock.mock.calls.some((call) => (call[0] as Array<string>).includes("kill-session"))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import { log } from "../../../shared"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
|
||||||
|
|
||||||
|
type TeamLayoutMember = { name: string; sessionId: string; color?: string }
|
||||||
|
|
||||||
|
type TeamLayoutResult = {
|
||||||
|
focusWindowId: string
|
||||||
|
gridWindowId: string
|
||||||
|
panesByMember: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canVisualize(): boolean {
|
||||||
|
return process.env.TMUX !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTmux(tmuxPath: string, args: Array<string>): Promise<{ success: boolean; output: string }> {
|
||||||
|
const proc = spawn([tmuxPath, ...args], { stdout: "pipe", stderr: "pipe" })
|
||||||
|
const outputPromise = new Response(proc.stdout).text()
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const output = await outputPromise
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
return { success: false, output: output.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, output: output.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindow(
|
||||||
|
tmuxPath: string,
|
||||||
|
sessionName: string,
|
||||||
|
windowName: string,
|
||||||
|
layout: "main-vertical" | "tiled",
|
||||||
|
members: Array<TeamLayoutMember>,
|
||||||
|
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
|
||||||
|
const base = await runTmux(tmuxPath, ["new-window", "-d", "-P", "-F", "#{window_id}", "-t", sessionName, "-n", windowName])
|
||||||
|
if (!base.success || !base.output) return null
|
||||||
|
|
||||||
|
const panesByMember: Record<string, string> = {}
|
||||||
|
const [lead, ...rest] = members
|
||||||
|
if (!lead) return null
|
||||||
|
|
||||||
|
const leadPane = await runTmux(tmuxPath, ["list-panes", "-t", `${sessionName}:${base.output}`, "-F", "#{pane_id}"])
|
||||||
|
if (!leadPane.success || !leadPane.output) return null
|
||||||
|
panesByMember[lead.name] = leadPane.output.split("\n")[0] ?? ""
|
||||||
|
|
||||||
|
for (const member of rest) {
|
||||||
|
const split = await runTmux(tmuxPath, ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", panesByMember[lead.name] ?? base.output, "sh", "-c", "cat >/dev/null"])
|
||||||
|
if (!split.success || !split.output) return null
|
||||||
|
panesByMember[member.name] = split.output
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutResult = await runTmux(tmuxPath, ["select-layout", "-t", `${sessionName}:${base.output}`, layout])
|
||||||
|
if (!layoutResult.success) return null
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const paneId = panesByMember[member.name]
|
||||||
|
if (!paneId) return null
|
||||||
|
const label = member.color ? `${member.name} ${member.color}` : member.name
|
||||||
|
const titleResult = await runTmux(tmuxPath, ["select-pane", "-t", paneId, "-T", label])
|
||||||
|
if (!titleResult.success) return null
|
||||||
|
await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-status", "top"])
|
||||||
|
await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-format", `#{pane_title} ${label}`])
|
||||||
|
await runTmux(tmuxPath, ["pipe-pane", "-I", "-t", paneId, "cat >/dev/null"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { windowId: base.output, panesByMember }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamLayout(
|
||||||
|
teamRunId: string,
|
||||||
|
members: Array<TeamLayoutMember>,
|
||||||
|
tmuxMgr: TmuxSessionManager,
|
||||||
|
): Promise<TeamLayoutResult | null> {
|
||||||
|
if (!canVisualize()) {
|
||||||
|
log("tmux visualization unavailable, skipping")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
void tmuxMgr
|
||||||
|
const tmuxPath = await getTmuxPath()
|
||||||
|
if (!tmuxPath) {
|
||||||
|
log("tmux visualization unavailable, skipping")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionName = `omo-team-${teamRunId}`
|
||||||
|
const created = await runTmux(tmuxPath, ["new-session", "-d", "-s", sessionName, "-P", "-F", "#{window_id}"])
|
||||||
|
if (!created.success || !created.output) return null
|
||||||
|
|
||||||
|
const focus = await createWindow(tmuxPath, sessionName, "focus", "main-vertical", members)
|
||||||
|
const grid = await createWindow(tmuxPath, sessionName, "grid", "tiled", members)
|
||||||
|
if (!focus || !grid) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusWindowId: focus.windowId,
|
||||||
|
gridWindowId: grid.windowId,
|
||||||
|
panesByMember: focus.panesByMember,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("tmux visualization unavailable, skipping", { error: String(error) })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTeamLayout(teamRunId: string, tmuxMgr: TmuxSessionManager): Promise<void> {
|
||||||
|
void tmuxMgr
|
||||||
|
if (!canVisualize()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmuxPath = await getTmuxPath()
|
||||||
|
if (!tmuxPath) return
|
||||||
|
await runTmux(tmuxPath, ["kill-session", "-t", `omo-team-${teamRunId}`])
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user