fix: apply tmux layout config during pane spawning (#1671)
This commit is contained in:
parent
fcf26d9898
commit
3a2f886357
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 } from "./types"
|
||||||
import type { PaneAction, WindowState } from "./types"
|
import { applyLayout, spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux"
|
||||||
import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux"
|
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import type {
|
||||||
|
ActionExecutorDeps,
|
||||||
|
ActionResult,
|
||||||
|
ExecuteContext,
|
||||||
|
} from "./action-executor-core"
|
||||||
|
import { executeActionWithDeps } from "./action-executor-core"
|
||||||
|
|
||||||
export interface ActionResult {
|
export type { ActionExecutorDeps, ActionResult, ExecuteContext } from "./action-executor-core"
|
||||||
success: boolean
|
|
||||||
paneId?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecuteActionsResult {
|
export interface ExecuteActionsResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -15,60 +16,19 @@ export interface ExecuteActionsResult {
|
|||||||
results: Array<{ action: PaneAction; result: ActionResult }>
|
results: Array<{ action: PaneAction; result: ActionResult }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecuteContext {
|
const DEFAULT_DEPS: ActionExecutorDeps = {
|
||||||
config: TmuxConfig
|
spawnTmuxPane,
|
||||||
serverUrl: string
|
closeTmuxPane,
|
||||||
windowState: WindowState
|
replaceTmuxPane,
|
||||||
}
|
applyLayout,
|
||||||
|
enforceMainPaneWidth,
|
||||||
async function enforceMainPane(windowState: WindowState): Promise<void> {
|
|
||||||
if (!windowState.mainPane) return
|
|
||||||
await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeAction(
|
export async function executeAction(
|
||||||
action: PaneAction,
|
action: PaneAction,
|
||||||
ctx: ExecuteContext
|
ctx: ExecuteContext
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
if (action.type === "close") {
|
return executeActionWithDeps(action, ctx, DEFAULT_DEPS)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeActions(
|
export async function executeActions(
|
||||||
|
|||||||
@ -55,6 +55,7 @@ mock.module('./pane-state-querier', () => ({
|
|||||||
mock.module('./action-executor', () => ({
|
mock.module('./action-executor', () => ({
|
||||||
executeActions: mockExecuteActions,
|
executeActions: mockExecuteActions,
|
||||||
executeAction: mockExecuteAction,
|
executeAction: mockExecuteAction,
|
||||||
|
executeActionWithDeps: mockExecuteAction,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../shared/tmux', () => {
|
mock.module('../../shared/tmux', () => {
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import type { TmuxLayout } from "../../../config/schema"
|
|||||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
|
||||||
export async function applyLayout(
|
export async function applyLayout(
|
||||||
tmux: string,
|
|
||||||
layout: TmuxLayout,
|
layout: TmuxLayout,
|
||||||
mainPaneSize: number,
|
mainPaneSize: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) return
|
||||||
|
|
||||||
const layoutProc = spawn([tmux, "select-layout", layout], {
|
const layoutProc = spawn([tmux, "select-layout", layout], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user