From 3a2f8863574f67506c7f537842d895285f9d99ed Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 01:36:01 +0900 Subject: [PATCH] fix: apply tmux layout config during pane spawning (#1671) --- .../tmux-subagent/action-executor-core.ts | 75 ++++++++++++ .../tmux-subagent/action-executor.test.ts | 113 ++++++++++++++++++ src/features/tmux-subagent/action-executor.ts | 72 +++-------- src/features/tmux-subagent/manager.test.ts | 1 + src/shared/tmux/tmux-utils/layout.ts | 4 +- 5 files changed, 208 insertions(+), 57 deletions(-) create mode 100644 src/features/tmux-subagent/action-executor-core.ts create mode 100644 src/features/tmux-subagent/action-executor.test.ts diff --git a/src/features/tmux-subagent/action-executor-core.ts b/src/features/tmux-subagent/action-executor-core.ts new file mode 100644 index 000000000..a1ae9c139 --- /dev/null +++ b/src/features/tmux-subagent/action-executor-core.ts @@ -0,0 +1,75 @@ +import type { TmuxConfig } from "../../config/schema" +import type { applyLayout, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane, spawnTmuxPane } from "../../shared/tmux" +import type { PaneAction, WindowState } from "./types" + +export interface ActionResult { + success: boolean + paneId?: string + error?: string +} + +export interface ExecuteContext { + config: TmuxConfig + serverUrl: string + windowState: WindowState +} + +export interface ActionExecutorDeps { + spawnTmuxPane: typeof spawnTmuxPane + closeTmuxPane: typeof closeTmuxPane + replaceTmuxPane: typeof replaceTmuxPane + applyLayout: typeof applyLayout + enforceMainPaneWidth: typeof enforceMainPaneWidth +} + +async function enforceMainPane(windowState: WindowState, deps: ActionExecutorDeps): Promise { + if (!windowState.mainPane) return + await deps.enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth) +} + +export async function executeActionWithDeps( + action: PaneAction, + ctx: ExecuteContext, + deps: ActionExecutorDeps, +): Promise { + if (action.type === "close") { + const success = await deps.closeTmuxPane(action.paneId) + if (success) { + await enforceMainPane(ctx.windowState, deps) + } + return { success } + } + + if (action.type === "replace") { + const result = await deps.replaceTmuxPane( + action.paneId, + action.newSessionId, + action.description, + ctx.config, + ctx.serverUrl, + ) + return { + success: result.success, + paneId: result.paneId, + } + } + + const result = await deps.spawnTmuxPane( + action.sessionId, + action.description, + ctx.config, + ctx.serverUrl, + action.targetPaneId, + action.splitDirection, + ) + + if (result.success) { + await deps.applyLayout(ctx.config.layout, ctx.config.main_pane_size) + await enforceMainPane(ctx.windowState, deps) + } + + return { + success: result.success, + paneId: result.paneId, + } +} diff --git a/src/features/tmux-subagent/action-executor.test.ts b/src/features/tmux-subagent/action-executor.test.ts new file mode 100644 index 000000000..90a85ca5c --- /dev/null +++ b/src/features/tmux-subagent/action-executor.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { TmuxConfig } from "../../config/schema" +import { executeActionWithDeps } from "./action-executor-core" +import type { ActionExecutorDeps, ExecuteContext } from "./action-executor-core" +import type { WindowState } from "./types" + +const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: "%7" })) +const mockCloseTmuxPane = mock(async () => true) +const mockEnforceMainPaneWidth = mock(async () => undefined) +const mockReplaceTmuxPane = mock(async () => ({ success: true, paneId: "%7" })) +const mockApplyLayout = mock(async () => undefined) + +const mockDeps: ActionExecutorDeps = { + spawnTmuxPane: mockSpawnTmuxPane, + closeTmuxPane: mockCloseTmuxPane, + enforceMainPaneWidth: mockEnforceMainPaneWidth, + replaceTmuxPane: mockReplaceTmuxPane, + applyLayout: mockApplyLayout, +} + +function createConfig(overrides?: Partial): TmuxConfig { + return { + enabled: true, + layout: "main-horizontal", + main_pane_size: 55, + main_pane_min_width: 120, + agent_pane_min_width: 40, + ...overrides, + } +} + +function createWindowState(overrides?: Partial): WindowState { + return { + windowWidth: 220, + windowHeight: 44, + mainPane: { + paneId: "%0", + width: 110, + height: 44, + left: 0, + top: 0, + title: "main", + isActive: true, + }, + agentPanes: [], + ...overrides, + } +} + +function createContext(overrides?: Partial): ExecuteContext { + return { + config: createConfig(), + serverUrl: "http://localhost:4096", + windowState: createWindowState(), + ...overrides, + } +} + +describe("executeAction", () => { + beforeEach(() => { + mockSpawnTmuxPane.mockClear() + mockCloseTmuxPane.mockClear() + mockEnforceMainPaneWidth.mockClear() + mockReplaceTmuxPane.mockClear() + mockApplyLayout.mockClear() + mockSpawnTmuxPane.mockImplementation(async () => ({ success: true, paneId: "%7" })) + }) + + test("applies configured tmux layout after successful spawn", async () => { + // given + // when + const result = await executeActionWithDeps( + { + type: "spawn", + sessionId: "ses_new", + description: "background task", + targetPaneId: "%0", + splitDirection: "-h", + }, + createContext(), + mockDeps, + ) + + // then + expect(result).toEqual({ success: true, paneId: "%7" }) + expect(mockApplyLayout).toHaveBeenCalledTimes(1) + expect(mockApplyLayout).toHaveBeenCalledWith("main-horizontal", 55) + expect(mockEnforceMainPaneWidth).toHaveBeenCalledTimes(1) + }) + + test("does not apply layout when spawn fails", async () => { + // given + mockSpawnTmuxPane.mockImplementationOnce(async () => ({ success: false })) + + // when + const result = await executeActionWithDeps( + { + type: "spawn", + sessionId: "ses_new", + description: "background task", + targetPaneId: "%0", + splitDirection: "-h", + }, + createContext(), + mockDeps, + ) + + // then + expect(result).toEqual({ success: false, paneId: undefined }) + expect(mockApplyLayout).not.toHaveBeenCalled() + expect(mockEnforceMainPaneWidth).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/tmux-subagent/action-executor.ts b/src/features/tmux-subagent/action-executor.ts index 02233bb22..7a4291109 100644 --- a/src/features/tmux-subagent/action-executor.ts +++ b/src/features/tmux-subagent/action-executor.ts @@ -1,13 +1,14 @@ -import type { TmuxConfig } from "../../config/schema" -import type { PaneAction, WindowState } from "./types" -import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux" +import type { PaneAction } from "./types" +import { applyLayout, spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux" import { log } from "../../shared" +import type { + ActionExecutorDeps, + ActionResult, + ExecuteContext, +} from "./action-executor-core" +import { executeActionWithDeps } from "./action-executor-core" -export interface ActionResult { - success: boolean - paneId?: string - error?: string -} +export type { ActionExecutorDeps, ActionResult, ExecuteContext } from "./action-executor-core" export interface ExecuteActionsResult { success: boolean @@ -15,60 +16,19 @@ export interface ExecuteActionsResult { results: Array<{ action: PaneAction; result: ActionResult }> } -export interface ExecuteContext { - config: TmuxConfig - serverUrl: string - windowState: WindowState -} - -async function enforceMainPane(windowState: WindowState): Promise { - if (!windowState.mainPane) return - await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth) +const DEFAULT_DEPS: ActionExecutorDeps = { + spawnTmuxPane, + closeTmuxPane, + replaceTmuxPane, + applyLayout, + enforceMainPaneWidth, } export async function executeAction( action: PaneAction, ctx: ExecuteContext ): Promise { - if (action.type === "close") { - const success = await closeTmuxPane(action.paneId) - if (success) { - await enforceMainPane(ctx.windowState) - } - return { success } - } - - if (action.type === "replace") { - const result = await replaceTmuxPane( - action.paneId, - action.newSessionId, - action.description, - ctx.config, - ctx.serverUrl - ) - return { - success: result.success, - paneId: result.paneId, - } - } - - const result = await spawnTmuxPane( - action.sessionId, - action.description, - ctx.config, - ctx.serverUrl, - action.targetPaneId, - action.splitDirection - ) - - if (result.success) { - await enforceMainPane(ctx.windowState) - } - - return { - success: result.success, - paneId: result.paneId, - } + return executeActionWithDeps(action, ctx, DEFAULT_DEPS) } export async function executeActions( diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 24e7cd313..b28c2cf72 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -55,6 +55,7 @@ mock.module('./pane-state-querier', () => ({ mock.module('./action-executor', () => ({ executeActions: mockExecuteActions, executeAction: mockExecuteAction, + executeActionWithDeps: mockExecuteAction, })) mock.module('../../shared/tmux', () => { diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts index d7900ff73..7aeeae024 100644 --- a/src/shared/tmux/tmux-utils/layout.ts +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -3,10 +3,12 @@ import type { TmuxLayout } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" export async function applyLayout( - tmux: string, layout: TmuxLayout, mainPaneSize: number, ): Promise { + const tmux = await getTmuxPath() + if (!tmux) return + const layoutProc = spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore",