Merge pull request #1898 from code-yeongyu/fix/1671-tmux-layout

fix: apply tmux layout config during pane spawning (#1671)
This commit is contained in:
YeonGyu-Kim
2026-02-17 02:01:29 +09:00
committed by GitHub
5 changed files with 208 additions and 57 deletions

View File

@@ -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<void> {
if (!windowState.mainPane) return
await deps.enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth)
}
export async function executeActionWithDeps(
action: PaneAction,
ctx: ExecuteContext,
deps: ActionExecutorDeps,
): Promise<ActionResult> {
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,
}
}

View File

@@ -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>): 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>): 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>): 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()
})
})

View File

@@ -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<void> {
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<ActionResult> {
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(

View File

@@ -55,6 +55,7 @@ mock.module('./pane-state-querier', () => ({
mock.module('./action-executor', () => ({
executeActions: mockExecuteActions,
executeAction: mockExecuteAction,
executeActionWithDeps: mockExecuteAction,
}))
mock.module('../../shared/tmux', () => {

View File

@@ -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<void> {
const tmux = await getTmuxPath()
if (!tmux) return
const layoutProc = spawn([tmux, "select-layout", layout], {
stdout: "ignore",
stderr: "ignore",