From e303feefd290220b59641216f15a24aae99abb61 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 18 Apr 2026 18:33:58 +0900 Subject: [PATCH] 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-, 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 --- .../team-mode/team-layout-tmux/index.ts | 1 + .../team-mode/team-layout-tmux/layout.test.ts | 78 ++++++++++++ .../team-mode/team-layout-tmux/layout.ts | 120 ++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/features/team-mode/team-layout-tmux/index.ts create mode 100644 src/features/team-mode/team-layout-tmux/layout.test.ts create mode 100644 src/features/team-mode/team-layout-tmux/layout.ts diff --git a/src/features/team-mode/team-layout-tmux/index.ts b/src/features/team-mode/team-layout-tmux/index.ts new file mode 100644 index 000000000..8858d5a4b --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/index.ts @@ -0,0 +1 @@ +export { canVisualize, createTeamLayout, removeTeamLayout } from "./layout" diff --git a/src/features/team-mode/team-layout-tmux/layout.test.ts b/src/features/team-mode/team-layout-tmux/layout.test.ts new file mode 100644 index 000000000..c23f878f4 --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/layout.test.ts @@ -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)).toContain("new-session") + expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("new-window") + expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("split-window") + expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).toContain("select-layout") + expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array)).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).includes("kill-session"))).toBe(true) + }) +}) diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts new file mode 100644 index 000000000..b41354d06 --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -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 +} + +export function canVisualize(): boolean { + return process.env.TMUX !== undefined +} + +async function runTmux(tmuxPath: string, args: Array): 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, +): Promise<{ windowId: string; panesByMember: Record } | 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 = {} + 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, + tmuxMgr: TmuxSessionManager, +): Promise { + 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 tmuxMgr + if (!canVisualize()) return + + try { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) return + await runTmux(tmuxPath, ["kill-session", "-t", `omo-team-${teamRunId}`]) + } catch { + return + } +}