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:
75
src/features/tmux-subagent/action-executor-core.ts
Normal file
75
src/features/tmux-subagent/action-executor-core.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
113
src/features/tmux-subagent/action-executor.test.ts
Normal file
113
src/features/tmux-subagent/action-executor.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -55,6 +55,7 @@ mock.module('./pane-state-querier', () => ({
|
||||
mock.module('./action-executor', () => ({
|
||||
executeActions: mockExecuteActions,
|
||||
executeAction: mockExecuteAction,
|
||||
executeActionWithDeps: mockExecuteAction,
|
||||
}))
|
||||
|
||||
mock.module('../../shared/tmux', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user