fix(tmux): prefer split-or-defer with FIFO deferred attach
This commit is contained in:
@@ -1,14 +1,21 @@
|
|||||||
import type { PaneAction } from "./types"
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
import { applyLayout, spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux"
|
import type { PaneAction, WindowState } from "./types"
|
||||||
|
import {
|
||||||
|
applyLayout,
|
||||||
|
spawnTmuxPane,
|
||||||
|
closeTmuxPane,
|
||||||
|
enforceMainPaneWidth,
|
||||||
|
replaceTmuxPane,
|
||||||
|
} from "../../shared/tmux"
|
||||||
|
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import type {
|
import type {
|
||||||
ActionExecutorDeps,
|
|
||||||
ActionResult,
|
ActionResult,
|
||||||
ExecuteContext,
|
ActionExecutorDeps,
|
||||||
} from "./action-executor-core"
|
} from "./action-executor-core"
|
||||||
import { executeActionWithDeps } from "./action-executor-core"
|
|
||||||
|
|
||||||
export type { ActionExecutorDeps, ActionResult, ExecuteContext } from "./action-executor-core"
|
export type { ActionExecutorDeps, ActionResult } from "./action-executor-core"
|
||||||
|
|
||||||
export interface ExecuteActionsResult {
|
export interface ExecuteActionsResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -16,19 +23,92 @@ export interface ExecuteActionsResult {
|
|||||||
results: Array<{ action: PaneAction; result: ActionResult }>
|
results: Array<{ action: PaneAction; result: ActionResult }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DEPS: ActionExecutorDeps = {
|
export interface ExecuteContext {
|
||||||
spawnTmuxPane,
|
config: TmuxConfig
|
||||||
closeTmuxPane,
|
serverUrl: string
|
||||||
replaceTmuxPane,
|
windowState: WindowState
|
||||||
applyLayout,
|
sourcePaneId?: string
|
||||||
enforceMainPaneWidth,
|
}
|
||||||
|
|
||||||
|
async function enforceMainPane(
|
||||||
|
windowState: WindowState,
|
||||||
|
config: TmuxConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!windowState.mainPane) return
|
||||||
|
await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth, {
|
||||||
|
mainPaneSize: config.main_pane_size,
|
||||||
|
mainPaneMinWidth: config.main_pane_min_width,
|
||||||
|
agentPaneMinWidth: config.agent_pane_min_width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceLayoutAndMainPane(ctx: ExecuteContext): Promise<void> {
|
||||||
|
const sourcePaneId = ctx.sourcePaneId
|
||||||
|
if (!sourcePaneId) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestState = await queryWindowState(sourcePaneId)
|
||||||
|
if (!latestState?.mainPane) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (tmux) {
|
||||||
|
await applyLayout(tmux, ctx.config.layout, ctx.config.main_pane_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
await enforceMainPane(latestState, ctx.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeAction(
|
export async function executeAction(
|
||||||
action: PaneAction,
|
action: PaneAction,
|
||||||
ctx: ExecuteContext
|
ctx: ExecuteContext
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
return executeActionWithDeps(action, ctx, DEFAULT_DEPS)
|
if (action.type === "close") {
|
||||||
|
const success = await closeTmuxPane(action.paneId)
|
||||||
|
if (success) {
|
||||||
|
await enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
return { success }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "replace") {
|
||||||
|
const result = await replaceTmuxPane(
|
||||||
|
action.paneId,
|
||||||
|
action.newSessionId,
|
||||||
|
action.description,
|
||||||
|
ctx.config,
|
||||||
|
ctx.serverUrl
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
await enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
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 enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
paneId: result.paneId,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeActions(
|
export async function executeActions(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
canSplitPane,
|
canSplitPane,
|
||||||
canSplitPaneAnyDirection,
|
canSplitPaneAnyDirection,
|
||||||
getBestSplitDirection,
|
getBestSplitDirection,
|
||||||
|
findSpawnTarget,
|
||||||
type SessionMapping
|
type SessionMapping
|
||||||
} from "./decision-engine"
|
} from "./decision-engine"
|
||||||
import type { WindowState, CapacityConfig, TmuxPaneInfo } from "./types"
|
import type { WindowState, CapacityConfig, TmuxPaneInfo } from "./types"
|
||||||
@@ -258,10 +259,31 @@ describe("decideSpawnActions", () => {
|
|||||||
expect(result.actions[0].type).toBe("spawn")
|
expect(result.actions[0].type).toBe("spawn")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("respects configured agent min width for split decisions", () => {
|
||||||
|
// given
|
||||||
|
const state = createWindowState(240, 44, [
|
||||||
|
{ paneId: "%1", width: 100, height: 44, left: 140, top: 0 },
|
||||||
|
])
|
||||||
|
const mappings: SessionMapping[] = [
|
||||||
|
{ sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") },
|
||||||
|
]
|
||||||
|
const strictConfig: CapacityConfig = {
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", strictConfig, mappings)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.canSpawn).toBe(false)
|
||||||
|
expect(result.actions).toHaveLength(0)
|
||||||
|
expect(result.reason).toContain("defer")
|
||||||
|
})
|
||||||
|
|
||||||
it("returns canSpawn=true when 0 agent panes exist and mainPane occupies full window width", () => {
|
it("returns canSpawn=true when 0 agent panes exist and mainPane occupies full window width", () => {
|
||||||
// given - tmux reports mainPane.width === windowWidth when no splits exist
|
// given - tmux reports mainPane.width === windowWidth when no splits exist
|
||||||
// agentAreaWidth = max(0, 252 - 252 - 1) = 0, which is < minPaneWidth
|
|
||||||
// but with 0 agent panes, the early return should be skipped
|
|
||||||
const windowWidth = 252
|
const windowWidth = 252
|
||||||
const windowHeight = 56
|
const windowHeight = 56
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
@@ -281,8 +303,7 @@ describe("decideSpawnActions", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("returns canSpawn=false when 0 agent panes and window genuinely too narrow to split", () => {
|
it("returns canSpawn=false when 0 agent panes and window genuinely too narrow to split", () => {
|
||||||
// given - window so narrow that even splitting mainPane wouldn't work
|
// given - window so narrow that even splitting mainPane would fail
|
||||||
// canSplitPane requires width >= 2*minPaneWidth + DIVIDER_SIZE = 2*40+1 = 81
|
|
||||||
const windowWidth = 70
|
const windowWidth = 70
|
||||||
const windowHeight = 56
|
const windowHeight = 56
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
@@ -295,14 +316,13 @@ describe("decideSpawnActions", () => {
|
|||||||
// when
|
// when
|
||||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
// then - should fail because mainPane itself is too small to split
|
// then
|
||||||
expect(result.canSpawn).toBe(false)
|
expect(result.canSpawn).toBe(false)
|
||||||
expect(result.reason).toContain("too small")
|
expect(result.reason).toContain("too small")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns canSpawn=false when agent panes exist but agent area too small", () => {
|
it("returns canSpawn=false when agent panes exist but agent area too small", () => {
|
||||||
// given - 1 agent pane exists, but agent area is below minPaneWidth
|
// given - 1 agent pane exists, and agent area is below minPaneWidth
|
||||||
// this verifies the early return still works for currentCount > 0
|
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: 180,
|
windowWidth: 180,
|
||||||
windowHeight: 44,
|
windowHeight: 44,
|
||||||
@@ -313,13 +333,13 @@ describe("decideSpawnActions", () => {
|
|||||||
// when
|
// when
|
||||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
// then - agent area = max(0, 180-160-1) = 19, which is < agentPaneWidth(40)
|
// then
|
||||||
expect(result.canSpawn).toBe(false)
|
expect(result.canSpawn).toBe(false)
|
||||||
expect(result.reason).toContain("too small")
|
expect(result.reason).toContain("too small")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("spawns at exact minimum splittable width with 0 agent panes", () => {
|
it("spawns at exact minimum splittable width with 0 agent panes", () => {
|
||||||
// given - canSplitPane requires width >= 2*agentPaneWidth + DIVIDER_SIZE = 2*40+1 = 81
|
// given
|
||||||
const exactThreshold = 2 * defaultConfig.agentPaneWidth + 1
|
const exactThreshold = 2 * defaultConfig.agentPaneWidth + 1
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: exactThreshold,
|
windowWidth: exactThreshold,
|
||||||
@@ -331,12 +351,12 @@ describe("decideSpawnActions", () => {
|
|||||||
// when
|
// when
|
||||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
// then - exactly at threshold should succeed
|
// then
|
||||||
expect(result.canSpawn).toBe(true)
|
expect(result.canSpawn).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects spawn 1 pixel below minimum splittable width with 0 agent panes", () => {
|
it("rejects spawn 1 pixel below minimum splittable width with 0 agent panes", () => {
|
||||||
// given - 1 below exact threshold
|
// given
|
||||||
const belowThreshold = 2 * defaultConfig.agentPaneWidth
|
const belowThreshold = 2 * defaultConfig.agentPaneWidth
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: belowThreshold,
|
windowWidth: belowThreshold,
|
||||||
@@ -348,11 +368,11 @@ describe("decideSpawnActions", () => {
|
|||||||
// when
|
// when
|
||||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
// then - 1 below threshold should fail
|
// then
|
||||||
expect(result.canSpawn).toBe(false)
|
expect(result.canSpawn).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("replaces oldest pane when existing panes are too small to split", () => {
|
it("closes oldest pane when existing panes are too small to split", () => {
|
||||||
// given - existing pane is below minimum splittable size
|
// given - existing pane is below minimum splittable size
|
||||||
const state = createWindowState(220, 30, [
|
const state = createWindowState(220, 30, [
|
||||||
{ paneId: "%1", width: 50, height: 15, left: 110, top: 0 },
|
{ paneId: "%1", width: 50, height: 15, left: 110, top: 0 },
|
||||||
@@ -366,8 +386,9 @@ describe("decideSpawnActions", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.canSpawn).toBe(true)
|
expect(result.canSpawn).toBe(true)
|
||||||
expect(result.actions.length).toBe(1)
|
expect(result.actions.length).toBe(2)
|
||||||
expect(result.actions[0].type).toBe("replace")
|
expect(result.actions[0].type).toBe("close")
|
||||||
|
expect(result.actions[1].type).toBe("spawn")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("can spawn when existing pane is large enough to split", () => {
|
it("can spawn when existing pane is large enough to split", () => {
|
||||||
@@ -429,6 +450,64 @@ describe("decideSpawnActions", () => {
|
|||||||
expect(result.canSpawn).toBe(false)
|
expect(result.canSpawn).toBe(false)
|
||||||
expect(result.reason).toBe("no main pane found")
|
expect(result.reason).toBe("no main pane found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses configured main pane size for split/defer decision", () => {
|
||||||
|
// given
|
||||||
|
const state = createWindowState(240, 44, [
|
||||||
|
{ paneId: "%1", width: 90, height: 44, left: 150, top: 0 },
|
||||||
|
])
|
||||||
|
const mappings: SessionMapping[] = [
|
||||||
|
{ sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") },
|
||||||
|
]
|
||||||
|
const wideMainConfig: CapacityConfig = {
|
||||||
|
mainPaneSize: 80,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", wideMainConfig, mappings)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.canSpawn).toBe(false)
|
||||||
|
expect(result.actions).toHaveLength(0)
|
||||||
|
expect(result.reason).toContain("defer")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("findSpawnTarget", () => {
|
||||||
|
it("uses deterministic vertical fallback order", () => {
|
||||||
|
// given
|
||||||
|
const state: WindowState = {
|
||||||
|
windowWidth: 320,
|
||||||
|
windowHeight: 44,
|
||||||
|
mainPane: {
|
||||||
|
paneId: "%0",
|
||||||
|
width: 160,
|
||||||
|
height: 44,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
title: "main",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
agentPanes: [
|
||||||
|
{ paneId: "%1", width: 70, height: 20, left: 170, top: 0, title: "a", isActive: false },
|
||||||
|
{ paneId: "%2", width: 120, height: 44, left: 240, top: 0, title: "b", isActive: false },
|
||||||
|
{ paneId: "%3", width: 120, height: 22, left: 240, top: 22, title: "c", isActive: false },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
mainPaneSize: 50,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const target = findSpawnTarget(state, config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(target).toEqual({ targetPaneId: "%2", splitDirection: "-v" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||||
import type { TmuxPaneInfo } from "./types"
|
import type { CapacityConfig, TmuxPaneInfo } from "./types"
|
||||||
import {
|
import {
|
||||||
DIVIDER_SIZE,
|
DIVIDER_SIZE,
|
||||||
MAIN_PANE_RATIO,
|
|
||||||
MAX_GRID_SIZE,
|
MAX_GRID_SIZE,
|
||||||
|
computeAgentAreaWidth,
|
||||||
} from "./tmux-grid-constants"
|
} from "./tmux-grid-constants"
|
||||||
|
|
||||||
export interface GridCapacity {
|
export interface GridCapacity {
|
||||||
@@ -24,16 +24,33 @@ export interface GridPlan {
|
|||||||
slotHeight: number
|
slotHeight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CapacityOptions = CapacityConfig | number | undefined
|
||||||
|
|
||||||
|
function resolveMinPaneWidth(options?: CapacityOptions): number {
|
||||||
|
if (typeof options === "number") {
|
||||||
|
return Math.max(1, options)
|
||||||
|
}
|
||||||
|
return MIN_PANE_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentAreaWidth(windowWidth: number, options?: CapacityOptions): number {
|
||||||
|
if (typeof options === "number") {
|
||||||
|
return computeAgentAreaWidth(windowWidth)
|
||||||
|
}
|
||||||
|
return computeAgentAreaWidth(windowWidth, options)
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateCapacity(
|
export function calculateCapacity(
|
||||||
windowWidth: number,
|
windowWidth: number,
|
||||||
windowHeight: number,
|
windowHeight: number,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
options?: CapacityOptions,
|
||||||
mainPaneWidth?: number,
|
mainPaneWidth?: number,
|
||||||
): GridCapacity {
|
): GridCapacity {
|
||||||
const availableWidth =
|
const availableWidth =
|
||||||
typeof mainPaneWidth === "number"
|
typeof mainPaneWidth === "number"
|
||||||
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
||||||
: Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
: resolveAgentAreaWidth(windowWidth, options)
|
||||||
|
const minPaneWidth = resolveMinPaneWidth(options)
|
||||||
const cols = Math.min(
|
const cols = Math.min(
|
||||||
MAX_GRID_SIZE,
|
MAX_GRID_SIZE,
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -59,15 +76,10 @@ export function computeGridPlan(
|
|||||||
windowWidth: number,
|
windowWidth: number,
|
||||||
windowHeight: number,
|
windowHeight: number,
|
||||||
paneCount: number,
|
paneCount: number,
|
||||||
|
options?: CapacityOptions,
|
||||||
mainPaneWidth?: number,
|
mainPaneWidth?: number,
|
||||||
minPaneWidth?: number,
|
|
||||||
): GridPlan {
|
): GridPlan {
|
||||||
const capacity = calculateCapacity(
|
const capacity = calculateCapacity(windowWidth, windowHeight, options, mainPaneWidth)
|
||||||
windowWidth,
|
|
||||||
windowHeight,
|
|
||||||
minPaneWidth ?? MIN_PANE_WIDTH,
|
|
||||||
mainPaneWidth,
|
|
||||||
)
|
|
||||||
const { cols: maxCols, rows: maxRows } = capacity
|
const { cols: maxCols, rows: maxRows } = capacity
|
||||||
|
|
||||||
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
||||||
@@ -91,9 +103,9 @@ export function computeGridPlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availableWidth =
|
const availableWidth =
|
||||||
typeof mainPaneWidth === "number"
|
typeof mainPaneWidth === "number"
|
||||||
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
||||||
: Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
: resolveAgentAreaWidth(windowWidth, options)
|
||||||
const slotWidth = Math.floor(availableWidth / bestCols)
|
const slotWidth = Math.floor(availableWidth / bestCols)
|
||||||
const slotHeight = Math.floor(windowHeight / bestRows)
|
const slotHeight = Math.floor(windowHeight / bestRows)
|
||||||
|
|
||||||
|
|||||||
145
src/features/tmux-subagent/layout-config.test.ts
Normal file
145
src/features/tmux-subagent/layout-config.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { decideSpawnActions, findSpawnTarget, type SessionMapping } from "./decision-engine"
|
||||||
|
import type { CapacityConfig, WindowState } from "./types"
|
||||||
|
|
||||||
|
function createState(
|
||||||
|
windowWidth: number,
|
||||||
|
windowHeight: number,
|
||||||
|
agentPanes: WindowState["agentPanes"],
|
||||||
|
): WindowState {
|
||||||
|
return {
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
|
mainPane: {
|
||||||
|
paneId: "%0",
|
||||||
|
width: Math.floor(windowWidth / 2),
|
||||||
|
height: windowHeight,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
title: "main",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
agentPanes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tmux layout-aware split behavior", () => {
|
||||||
|
it("uses -v for first spawn in main-horizontal layout", () => {
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
layout: "main-horizontal",
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
const state = createState(220, 44, [])
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(state, "ses-1", "agent", config, [])
|
||||||
|
|
||||||
|
expect(decision.canSpawn).toBe(true)
|
||||||
|
expect(decision.actions[0]).toMatchObject({
|
||||||
|
type: "spawn",
|
||||||
|
splitDirection: "-v",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses -h for first spawn in main-vertical layout", () => {
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
layout: "main-vertical",
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
const state = createState(220, 44, [])
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(state, "ses-1", "agent", config, [])
|
||||||
|
|
||||||
|
expect(decision.canSpawn).toBe(true)
|
||||||
|
expect(decision.actions[0]).toMatchObject({
|
||||||
|
type: "spawn",
|
||||||
|
splitDirection: "-h",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers horizontal split target in main-horizontal layout", () => {
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
layout: "main-horizontal",
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
const state = createState(260, 60, [
|
||||||
|
{
|
||||||
|
paneId: "%1",
|
||||||
|
width: 120,
|
||||||
|
height: 30,
|
||||||
|
left: 0,
|
||||||
|
top: 30,
|
||||||
|
title: "agent",
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const target = findSpawnTarget(state, config)
|
||||||
|
|
||||||
|
expect(target).toEqual({ targetPaneId: "%1", splitDirection: "-h" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("defers when strict main-horizontal cannot split", () => {
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
layout: "main-horizontal",
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
const state = createState(220, 44, [
|
||||||
|
{
|
||||||
|
paneId: "%1",
|
||||||
|
width: 60,
|
||||||
|
height: 44,
|
||||||
|
left: 0,
|
||||||
|
top: 22,
|
||||||
|
title: "old",
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const mappings: SessionMapping[] = [
|
||||||
|
{ sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") },
|
||||||
|
]
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(state, "new-ses", "agent", config, mappings)
|
||||||
|
|
||||||
|
expect(decision.canSpawn).toBe(false)
|
||||||
|
expect(decision.actions).toHaveLength(0)
|
||||||
|
expect(decision.reason).toContain("defer")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("still spawns in narrow main-vertical when vertical split is possible", () => {
|
||||||
|
const config: CapacityConfig = {
|
||||||
|
layout: "main-vertical",
|
||||||
|
mainPaneSize: 60,
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
const state = createState(169, 40, [
|
||||||
|
{
|
||||||
|
paneId: "%1",
|
||||||
|
width: 48,
|
||||||
|
height: 40,
|
||||||
|
left: 121,
|
||||||
|
top: 0,
|
||||||
|
title: "agent",
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(state, "new-ses", "agent", config, [])
|
||||||
|
|
||||||
|
expect(decision.canSpawn).toBe(true)
|
||||||
|
expect(decision.actions).toHaveLength(1)
|
||||||
|
expect(decision.actions[0]).toMatchObject({
|
||||||
|
type: "spawn",
|
||||||
|
targetPaneId: "%1",
|
||||||
|
splitDirection: "-v",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -156,7 +156,15 @@ describe('TmuxSessionManager', () => {
|
|||||||
// given
|
// given
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
const { TmuxSessionManager } = await import('./manager')
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext({
|
||||||
|
sessionStatusResult: {
|
||||||
|
data: {
|
||||||
|
ses_1: { type: 'running' },
|
||||||
|
ses_2: { type: 'running' },
|
||||||
|
ses_3: { type: 'running' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
const config: TmuxConfig = {
|
const config: TmuxConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
layout: 'main-vertical',
|
layout: 'main-vertical',
|
||||||
@@ -176,7 +184,13 @@ describe('TmuxSessionManager', () => {
|
|||||||
// given
|
// given
|
||||||
mockIsInsideTmux.mockReturnValue(false)
|
mockIsInsideTmux.mockReturnValue(false)
|
||||||
const { TmuxSessionManager } = await import('./manager')
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext({
|
||||||
|
sessionStatusResult: {
|
||||||
|
data: {
|
||||||
|
ses_once: { type: 'running' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
const config: TmuxConfig = {
|
const config: TmuxConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
layout: 'main-vertical',
|
layout: 'main-vertical',
|
||||||
@@ -386,7 +400,7 @@ describe('TmuxSessionManager', () => {
|
|||||||
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('replaces oldest agent when unsplittable (small window)', async () => {
|
test('defers attach when unsplittable (small window)', async () => {
|
||||||
// given - small window where split is not possible
|
// given - small window where split is not possible
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
mockQueryWindowState.mockImplementation(async () =>
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
@@ -423,13 +437,224 @@ describe('TmuxSessionManager', () => {
|
|||||||
createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task')
|
createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task')
|
||||||
)
|
)
|
||||||
|
|
||||||
// then - with small window, replace action is used instead of close+spawn
|
// then - with small window, manager defers instead of replacing
|
||||||
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
const call = mockExecuteActions.mock.calls[0]
|
expect((manager as any).deferredQueue).toEqual(['ses_new'])
|
||||||
expect(call).toBeDefined()
|
})
|
||||||
const actionsArg = call![0]
|
|
||||||
expect(actionsArg).toHaveLength(1)
|
test('keeps deferred queue idempotent for duplicate session.created events', async () => {
|
||||||
expect(actionsArg[0].type).toBe('replace')
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
|
createWindowState({
|
||||||
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
|
agentPanes: [
|
||||||
|
{
|
||||||
|
paneId: '%1',
|
||||||
|
width: 80,
|
||||||
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
|
title: 'old',
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = createMockContext()
|
||||||
|
const config: TmuxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'main-vertical',
|
||||||
|
main_pane_size: 60,
|
||||||
|
main_pane_min_width: 120,
|
||||||
|
agent_pane_min_width: 40,
|
||||||
|
}
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_dup', 'ses_parent', 'Duplicate Task')
|
||||||
|
)
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_dup', 'ses_parent', 'Duplicate Task')
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_dup'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auto-attaches deferred sessions in FIFO order', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
|
createWindowState({
|
||||||
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
|
agentPanes: [
|
||||||
|
{
|
||||||
|
paneId: '%1',
|
||||||
|
width: 80,
|
||||||
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
|
title: 'old',
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const attachOrder: string[] = []
|
||||||
|
mockExecuteActions.mockImplementation(async (actions) => {
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.type === 'spawn') {
|
||||||
|
attachOrder.push(action.sessionId)
|
||||||
|
trackedSessions.add(action.sessionId)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
spawnedPaneId: `%${action.sessionId}`,
|
||||||
|
results: [{ action, result: { success: true, paneId: `%${action.sessionId}` } }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, results: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = createMockContext()
|
||||||
|
const config: TmuxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'main-vertical',
|
||||||
|
main_pane_size: 60,
|
||||||
|
main_pane_min_width: 120,
|
||||||
|
agent_pane_min_width: 40,
|
||||||
|
}
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1'))
|
||||||
|
await manager.onSessionCreated(createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2'))
|
||||||
|
await manager.onSessionCreated(createSessionCreatedEvent('ses_3', 'ses_parent', 'Task 3'))
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_1', 'ses_2', 'ses_3'])
|
||||||
|
|
||||||
|
// when
|
||||||
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
|
await (manager as any).tryAttachDeferredSession()
|
||||||
|
await (manager as any).tryAttachDeferredSession()
|
||||||
|
await (manager as any).tryAttachDeferredSession()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(attachOrder).toEqual(['ses_1', 'ses_2', 'ses_3'])
|
||||||
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not attach deferred session more than once across repeated retries', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
|
createWindowState({
|
||||||
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
|
agentPanes: [
|
||||||
|
{
|
||||||
|
paneId: '%1',
|
||||||
|
width: 80,
|
||||||
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
|
title: 'old',
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
let attachCount = 0
|
||||||
|
mockExecuteActions.mockImplementation(async (actions) => {
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.type === 'spawn') {
|
||||||
|
attachCount += 1
|
||||||
|
trackedSessions.add(action.sessionId)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
spawnedPaneId: `%${action.sessionId}`,
|
||||||
|
results: [{ action, result: { success: true, paneId: `%${action.sessionId}` } }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, results: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = createMockContext()
|
||||||
|
const config: TmuxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'main-vertical',
|
||||||
|
main_pane_size: 60,
|
||||||
|
main_pane_min_width: 120,
|
||||||
|
agent_pane_min_width: 40,
|
||||||
|
}
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_once', 'ses_parent', 'Task Once')
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
|
await (manager as any).tryAttachDeferredSession()
|
||||||
|
await (manager as any).tryAttachDeferredSession()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(attachCount).toBe(1)
|
||||||
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removes deferred session when session is deleted before attach', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
|
createWindowState({
|
||||||
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
|
agentPanes: [
|
||||||
|
{
|
||||||
|
paneId: '%1',
|
||||||
|
width: 80,
|
||||||
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
|
title: 'old',
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = createMockContext()
|
||||||
|
const config: TmuxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'main-vertical',
|
||||||
|
main_pane_size: 60,
|
||||||
|
main_pane_min_width: 120,
|
||||||
|
agent_pane_min_width: 40,
|
||||||
|
}
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_pending', 'ses_parent', 'Pending Task')
|
||||||
|
)
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_pending'])
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionDeleted({ sessionID: 'ses_pending' })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
|
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -680,7 +905,7 @@ describe('DecisionEngine', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns replace when split not possible', async () => {
|
test('returns canSpawn=false when split not possible', async () => {
|
||||||
// given - small window where split is never possible
|
// given - small window where split is never possible
|
||||||
const { decideSpawnActions } = await import('./decision-engine')
|
const { decideSpawnActions } = await import('./decision-engine')
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
@@ -720,10 +945,10 @@ describe('DecisionEngine', () => {
|
|||||||
sessionMappings
|
sessionMappings
|
||||||
)
|
)
|
||||||
|
|
||||||
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used
|
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so attach is deferred
|
||||||
expect(decision.canSpawn).toBe(true)
|
expect(decision.canSpawn).toBe(false)
|
||||||
expect(decision.actions).toHaveLength(1)
|
expect(decision.actions).toHaveLength(0)
|
||||||
expect(decision.actions[0].type).toBe('replace')
|
expect(decision.reason).toContain('defer')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns canSpawn=false when window too small', async () => {
|
test('returns canSpawn=false when window too small', async () => {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ interface SessionCreatedEvent {
|
|||||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeferredSession {
|
||||||
|
sessionId: string
|
||||||
|
title: string
|
||||||
|
queuedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmuxUtilDeps {
|
export interface TmuxUtilDeps {
|
||||||
isInsideTmux: () => boolean
|
isInsideTmux: () => boolean
|
||||||
getCurrentPaneId: () => string | undefined
|
getCurrentPaneId: () => string | undefined
|
||||||
@@ -48,6 +54,10 @@ export class TmuxSessionManager {
|
|||||||
private sourcePaneId: string | undefined
|
private sourcePaneId: string | undefined
|
||||||
private sessions = new Map<string, TrackedSession>()
|
private sessions = new Map<string, TrackedSession>()
|
||||||
private pendingSessions = new Set<string>()
|
private pendingSessions = new Set<string>()
|
||||||
|
private spawnQueue: Promise<void> = Promise.resolve()
|
||||||
|
private deferredSessions = new Map<string, DeferredSession>()
|
||||||
|
private deferredQueue: string[] = []
|
||||||
|
private deferredAttachInterval?: ReturnType<typeof setInterval>
|
||||||
private deps: TmuxUtilDeps
|
private deps: TmuxUtilDeps
|
||||||
private pollingManager: TmuxPollingManager
|
private pollingManager: TmuxPollingManager
|
||||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||||
@@ -75,6 +85,8 @@ export class TmuxSessionManager {
|
|||||||
|
|
||||||
private getCapacityConfig(): CapacityConfig {
|
private getCapacityConfig(): CapacityConfig {
|
||||||
return {
|
return {
|
||||||
|
layout: this.tmuxConfig.layout,
|
||||||
|
mainPaneSize: this.tmuxConfig.main_pane_size,
|
||||||
mainPaneMinWidth: this.tmuxConfig.main_pane_min_width,
|
mainPaneMinWidth: this.tmuxConfig.main_pane_min_width,
|
||||||
agentPaneWidth: this.tmuxConfig.agent_pane_min_width,
|
agentPaneWidth: this.tmuxConfig.agent_pane_min_width,
|
||||||
}
|
}
|
||||||
@@ -88,6 +100,129 @@ export class TmuxSessionManager {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enqueueDeferredSession(sessionId: string, title: string): void {
|
||||||
|
if (this.deferredSessions.has(sessionId)) return
|
||||||
|
this.deferredSessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
queuedAt: new Date(),
|
||||||
|
})
|
||||||
|
this.deferredQueue.push(sessionId)
|
||||||
|
log("[tmux-session-manager] deferred session queued", {
|
||||||
|
sessionId,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
})
|
||||||
|
this.startDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeDeferredSession(sessionId: string): void {
|
||||||
|
if (!this.deferredSessions.delete(sessionId)) return
|
||||||
|
this.deferredQueue = this.deferredQueue.filter((id) => id !== sessionId)
|
||||||
|
log("[tmux-session-manager] deferred session removed", {
|
||||||
|
sessionId,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
})
|
||||||
|
if (this.deferredQueue.length === 0) {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startDeferredAttachLoop(): void {
|
||||||
|
if (this.deferredAttachInterval) return
|
||||||
|
this.deferredAttachInterval = setInterval(() => {
|
||||||
|
void this.enqueueSpawn(async () => {
|
||||||
|
await this.tryAttachDeferredSession()
|
||||||
|
})
|
||||||
|
}, POLL_INTERVAL_BACKGROUND_MS)
|
||||||
|
log("[tmux-session-manager] deferred attach polling started", {
|
||||||
|
intervalMs: POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopDeferredAttachLoop(): void {
|
||||||
|
if (!this.deferredAttachInterval) return
|
||||||
|
clearInterval(this.deferredAttachInterval)
|
||||||
|
this.deferredAttachInterval = undefined
|
||||||
|
log("[tmux-session-manager] deferred attach polling stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAttachDeferredSession(): Promise<void> {
|
||||||
|
if (!this.sourcePaneId) return
|
||||||
|
const sessionId = this.deferredQueue[0]
|
||||||
|
if (!sessionId) {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const deferred = this.deferredSessions.get(sessionId)
|
||||||
|
if (!deferred) {
|
||||||
|
this.deferredQueue.shift()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await queryWindowState(this.sourcePaneId)
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(
|
||||||
|
state,
|
||||||
|
sessionId,
|
||||||
|
deferred.title,
|
||||||
|
this.getCapacityConfig(),
|
||||||
|
this.getSessionMappings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!decision.canSpawn || decision.actions.length === 0) {
|
||||||
|
log("[tmux-session-manager] deferred session still waiting for capacity", {
|
||||||
|
sessionId,
|
||||||
|
reason: decision.reason,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeActions(decision.actions, {
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success || !result.spawnedPaneId) {
|
||||||
|
log("[tmux-session-manager] deferred session attach failed", {
|
||||||
|
sessionId,
|
||||||
|
results: result.results.map((r) => ({
|
||||||
|
type: r.action.type,
|
||||||
|
success: r.result.success,
|
||||||
|
error: r.result.error,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||||
|
if (!sessionReady) {
|
||||||
|
log("[tmux-session-manager] deferred session not ready after timeout", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
this.sessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
description: deferred.title,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
lastSeenAt: new Date(now),
|
||||||
|
})
|
||||||
|
this.removeDeferredSession(sessionId)
|
||||||
|
this.pollingManager.startPolling()
|
||||||
|
log("[tmux-session-manager] deferred session attached", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
sessionReady,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async waitForSessionReady(sessionId: string): Promise<boolean> {
|
private async waitForSessionReady(sessionId: string): Promise<boolean> {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
@@ -138,7 +273,11 @@ export class TmuxSessionManager {
|
|||||||
const sessionId = info.id
|
const sessionId = info.id
|
||||||
const title = info.title ?? "Subagent"
|
const title = info.title ?? "Subagent"
|
||||||
|
|
||||||
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
|
if (
|
||||||
|
this.sessions.has(sessionId) ||
|
||||||
|
this.pendingSessions.has(sessionId) ||
|
||||||
|
this.deferredSessions.has(sessionId)
|
||||||
|
) {
|
||||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,15 +286,17 @@ export class TmuxSessionManager {
|
|||||||
log("[tmux-session-manager] no source pane id")
|
log("[tmux-session-manager] no source pane id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const sourcePaneId = this.sourcePaneId
|
||||||
|
|
||||||
this.pendingSessions.add(sessionId)
|
this.pendingSessions.add(sessionId)
|
||||||
|
|
||||||
try {
|
await this.enqueueSpawn(async () => {
|
||||||
const state = await queryWindowState(this.sourcePaneId)
|
try {
|
||||||
if (!state) {
|
const state = await queryWindowState(sourcePaneId)
|
||||||
log("[tmux-session-manager] failed to query window state")
|
if (!state) {
|
||||||
return
|
log("[tmux-session-manager] failed to query window state")
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log("[tmux-session-manager] window state queried", {
|
log("[tmux-session-manager] window state queried", {
|
||||||
windowWidth: state.windowWidth,
|
windowWidth: state.windowWidth,
|
||||||
@@ -164,13 +305,13 @@ export class TmuxSessionManager {
|
|||||||
agentPanes: state.agentPanes.map((p) => p.paneId),
|
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||||
})
|
})
|
||||||
|
|
||||||
const decision = decideSpawnActions(
|
const decision = decideSpawnActions(
|
||||||
state,
|
state,
|
||||||
sessionId,
|
sessionId,
|
||||||
title,
|
title,
|
||||||
this.getCapacityConfig(),
|
this.getCapacityConfig(),
|
||||||
this.getSessionMappings()
|
this.getSessionMappings()
|
||||||
)
|
)
|
||||||
|
|
||||||
log("[tmux-session-manager] spawn decision", {
|
log("[tmux-session-manager] spawn decision", {
|
||||||
canSpawn: decision.canSpawn,
|
canSpawn: decision.canSpawn,
|
||||||
@@ -183,39 +324,70 @@ export class TmuxSessionManager {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!decision.canSpawn) {
|
if (!decision.canSpawn) {
|
||||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||||
return
|
this.enqueueDeferredSession(sessionId, title)
|
||||||
}
|
return
|
||||||
|
|
||||||
const result = await executeActions(
|
|
||||||
decision.actions,
|
|
||||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const { action, result: actionResult } of result.results) {
|
|
||||||
if (action.type === "close" && actionResult.success) {
|
|
||||||
this.sessions.delete(action.sessionId)
|
|
||||||
log("[tmux-session-manager] removed closed session from cache", {
|
|
||||||
sessionId: action.sessionId,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (action.type === "replace" && actionResult.success) {
|
|
||||||
this.sessions.delete(action.oldSessionId)
|
|
||||||
log("[tmux-session-manager] removed replaced session from cache", {
|
|
||||||
oldSessionId: action.oldSessionId,
|
|
||||||
newSessionId: action.newSessionId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success && result.spawnedPaneId) {
|
const result = await executeActions(
|
||||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
decision.actions,
|
||||||
|
{
|
||||||
if (!sessionReady) {
|
config: this.tmuxConfig,
|
||||||
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { action, result: actionResult } of result.results) {
|
||||||
|
if (action.type === "close" && actionResult.success) {
|
||||||
|
this.sessions.delete(action.sessionId)
|
||||||
|
log("[tmux-session-manager] removed closed session from cache", {
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (action.type === "replace" && actionResult.success) {
|
||||||
|
this.sessions.delete(action.oldSessionId)
|
||||||
|
log("[tmux-session-manager] removed replaced session from cache", {
|
||||||
|
oldSessionId: action.oldSessionId,
|
||||||
|
newSessionId: action.newSessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success && result.spawnedPaneId) {
|
||||||
|
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||||
|
|
||||||
|
if (!sessionReady) {
|
||||||
|
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
this.sessions.set(sessionId, {
|
||||||
sessionId,
|
sessionId,
|
||||||
paneId: result.spawnedPaneId,
|
paneId: result.spawnedPaneId,
|
||||||
|
description: title,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
lastSeenAt: new Date(now),
|
||||||
|
})
|
||||||
|
log("[tmux-session-manager] pane spawned and tracked", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
sessionReady,
|
||||||
|
})
|
||||||
|
this.pollingManager.startPolling()
|
||||||
|
} else {
|
||||||
|
log("[tmux-session-manager] spawn failed", {
|
||||||
|
success: result.success,
|
||||||
|
results: result.results.map((r) => ({
|
||||||
|
type: r.action.type,
|
||||||
|
success: r.result.success,
|
||||||
|
error: r.result.error,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
await executeAction(
|
await executeAction(
|
||||||
@@ -225,40 +397,30 @@ export class TmuxSessionManager {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
const now = Date.now()
|
this.pendingSessions.delete(sessionId)
|
||||||
this.sessions.set(sessionId, {
|
|
||||||
sessionId,
|
|
||||||
paneId: result.spawnedPaneId,
|
|
||||||
description: title,
|
|
||||||
createdAt: new Date(now),
|
|
||||||
lastSeenAt: new Date(now),
|
|
||||||
})
|
|
||||||
log("[tmux-session-manager] pane spawned and tracked", {
|
|
||||||
sessionId,
|
|
||||||
paneId: result.spawnedPaneId,
|
|
||||||
sessionReady,
|
|
||||||
})
|
|
||||||
this.pollingManager.startPolling()
|
|
||||||
} else {
|
|
||||||
log("[tmux-session-manager] spawn failed", {
|
|
||||||
success: result.success,
|
|
||||||
results: result.results.map((r) => ({
|
|
||||||
type: r.action.type,
|
|
||||||
success: r.result.success,
|
|
||||||
error: r.result.error,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} finally {
|
})
|
||||||
this.pendingSessions.delete(sessionId)
|
}
|
||||||
}
|
|
||||||
|
private async enqueueSpawn(run: () => Promise<void>): Promise<void> {
|
||||||
|
this.spawnQueue = this.spawnQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(run)
|
||||||
|
.catch((err) => {
|
||||||
|
log("[tmux-session-manager] spawn queue task failed", {
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await this.spawnQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||||
if (!this.isEnabled()) return
|
if (!this.isEnabled()) return
|
||||||
if (!this.sourcePaneId) return
|
if (!this.sourcePaneId) return
|
||||||
|
|
||||||
|
this.removeDeferredSession(event.sessionID)
|
||||||
|
|
||||||
const tracked = this.sessions.get(event.sessionID)
|
const tracked = this.sessions.get(event.sessionID)
|
||||||
if (!tracked) return
|
if (!tracked) return
|
||||||
|
|
||||||
@@ -272,7 +434,12 @@ export class TmuxSessionManager {
|
|||||||
|
|
||||||
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
||||||
if (closeAction) {
|
if (closeAction) {
|
||||||
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
|
await executeAction(closeAction, {
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.delete(event.sessionID)
|
this.sessions.delete(event.sessionID)
|
||||||
@@ -296,7 +463,12 @@ export class TmuxSessionManager {
|
|||||||
if (state) {
|
if (state) {
|
||||||
await executeAction(
|
await executeAction(
|
||||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
{
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +486,9 @@ export class TmuxSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cleanup(): Promise<void> {
|
async cleanup(): Promise<void> {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
this.deferredQueue = []
|
||||||
|
this.deferredSessions.clear()
|
||||||
this.pollingManager.stopPolling()
|
this.pollingManager.stopPolling()
|
||||||
|
|
||||||
if (this.sessions.size > 0) {
|
if (this.sessions.size > 0) {
|
||||||
@@ -324,7 +499,12 @@ export class TmuxSessionManager {
|
|||||||
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
||||||
executeAction(
|
executeAction(
|
||||||
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
||||||
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
{
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
}
|
||||||
).catch((err) =>
|
).catch((err) =>
|
||||||
log("[tmux-session-manager] cleanup error for pane", {
|
log("[tmux-session-manager] cleanup error for pane", {
|
||||||
paneId: s.paneId,
|
paneId: s.paneId,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { MIN_PANE_WIDTH } from "./types"
|
|
||||||
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||||
import {
|
import {
|
||||||
DIVIDER_SIZE,
|
DIVIDER_SIZE,
|
||||||
MAX_COLS,
|
MAX_COLS,
|
||||||
MAX_ROWS,
|
MAX_ROWS,
|
||||||
MIN_SPLIT_HEIGHT,
|
MIN_SPLIT_HEIGHT,
|
||||||
} from "./tmux-grid-constants"
|
} from "./tmux-grid-constants"
|
||||||
|
import { MIN_PANE_WIDTH } from "./types"
|
||||||
|
|
||||||
function minSplitWidthFor(minPaneWidth: number): number {
|
function getMinSplitWidth(minPaneWidth?: number): number {
|
||||||
return 2 * minPaneWidth + DIVIDER_SIZE
|
const width = Math.max(1, minPaneWidth ?? MIN_PANE_WIDTH)
|
||||||
|
return 2 * width + DIVIDER_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getColumnCount(paneCount: number): number {
|
export function getColumnCount(paneCount: number): number {
|
||||||
@@ -25,16 +26,16 @@ export function getColumnWidth(agentAreaWidth: number, paneCount: number): numbe
|
|||||||
export function isSplittableAtCount(
|
export function isSplittableAtCount(
|
||||||
agentAreaWidth: number,
|
agentAreaWidth: number,
|
||||||
paneCount: number,
|
paneCount: number,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
minPaneWidth?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
||||||
return columnWidth >= minSplitWidthFor(minPaneWidth)
|
return columnWidth >= getMinSplitWidth(minPaneWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMinimalEvictions(
|
export function findMinimalEvictions(
|
||||||
agentAreaWidth: number,
|
agentAreaWidth: number,
|
||||||
currentCount: number,
|
currentCount: number,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
minPaneWidth?: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
for (let k = 1; k <= currentCount; k++) {
|
for (let k = 1; k <= currentCount; k++) {
|
||||||
if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) {
|
if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) {
|
||||||
@@ -47,30 +48,26 @@ export function findMinimalEvictions(
|
|||||||
export function canSplitPane(
|
export function canSplitPane(
|
||||||
pane: TmuxPaneInfo,
|
pane: TmuxPaneInfo,
|
||||||
direction: SplitDirection,
|
direction: SplitDirection,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
minPaneWidth?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (direction === "-h") {
|
if (direction === "-h") {
|
||||||
return pane.width >= minSplitWidthFor(minPaneWidth)
|
return pane.width >= getMinSplitWidth(minPaneWidth)
|
||||||
}
|
}
|
||||||
return pane.height >= MIN_SPLIT_HEIGHT
|
return pane.height >= MIN_SPLIT_HEIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canSplitPaneAnyDirection(pane: TmuxPaneInfo, minPaneWidth: number = MIN_PANE_WIDTH): boolean {
|
export function canSplitPaneAnyDirection(
|
||||||
return canSplitPaneAnyDirectionWithMinWidth(pane, minPaneWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canSplitPaneAnyDirectionWithMinWidth(
|
|
||||||
pane: TmuxPaneInfo,
|
pane: TmuxPaneInfo,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
minPaneWidth?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
return pane.width >= minSplitWidthFor(minPaneWidth) || pane.height >= MIN_SPLIT_HEIGHT
|
return pane.width >= getMinSplitWidth(minPaneWidth) || pane.height >= MIN_SPLIT_HEIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBestSplitDirection(
|
export function getBestSplitDirection(
|
||||||
pane: TmuxPaneInfo,
|
pane: TmuxPaneInfo,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
minPaneWidth?: number,
|
||||||
): SplitDirection | null {
|
): SplitDirection | null {
|
||||||
const canH = pane.width >= minSplitWidthFor(minPaneWidth)
|
const canH = pane.width >= getMinSplitWidth(minPaneWidth)
|
||||||
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
|
||||||
if (!canH && !canV) return null
|
if (!canH && !canV) return null
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|||||||
"-t",
|
"-t",
|
||||||
sourcePaneId,
|
sourcePaneId,
|
||||||
"-F",
|
"-F",
|
||||||
"#{pane_id},#{pane_width},#{pane_height},#{pane_left},#{pane_top},#{pane_title},#{pane_active},#{window_width},#{window_height}",
|
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_title}\t#{pane_active}\t#{window_width}\t#{window_height}",
|
||||||
],
|
],
|
||||||
{ stdout: "pipe", stderr: "pipe" }
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|||||||
const panes: TmuxPaneInfo[] = []
|
const panes: TmuxPaneInfo[] = []
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const [paneId, widthStr, heightStr, leftStr, topStr, title, activeStr, windowWidthStr, windowHeightStr] = line.split(",")
|
const [paneId, widthStr, heightStr, leftStr, topStr, title, activeStr, windowWidthStr, windowHeightStr] = line.split("\t")
|
||||||
const width = parseInt(widthStr, 10)
|
const width = parseInt(widthStr, 10)
|
||||||
const height = parseInt(heightStr, 10)
|
const height = parseInt(heightStr, 10)
|
||||||
const left = parseInt(leftStr, 10)
|
const left = parseInt(leftStr, 10)
|
||||||
@@ -51,9 +51,21 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|||||||
|
|
||||||
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
|
|
||||||
const mainPane = panes.find((p) => p.paneId === sourcePaneId)
|
const mainPane = panes.reduce<TmuxPaneInfo | null>((selected, pane) => {
|
||||||
|
if (!selected) return pane
|
||||||
|
if (pane.left !== selected.left) {
|
||||||
|
return pane.left < selected.left ? pane : selected
|
||||||
|
}
|
||||||
|
if (pane.width !== selected.width) {
|
||||||
|
return pane.width > selected.width ? pane : selected
|
||||||
|
}
|
||||||
|
if (pane.top !== selected.top) {
|
||||||
|
return pane.top < selected.top ? pane : selected
|
||||||
|
}
|
||||||
|
return pane.paneId === sourcePaneId ? pane : selected
|
||||||
|
}, null)
|
||||||
if (!mainPane) {
|
if (!mainPane) {
|
||||||
log("[pane-state-querier] CRITICAL: sourcePaneId not found in panes", {
|
log("[pane-state-querier] CRITICAL: failed to determine main pane", {
|
||||||
sourcePaneId,
|
sourcePaneId,
|
||||||
availablePanes: panes.map((p) => p.paneId),
|
availablePanes: panes.map((p) => p.paneId),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
TmuxPaneInfo,
|
TmuxPaneInfo,
|
||||||
WindowState,
|
WindowState,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { DIVIDER_SIZE } from "./tmux-grid-constants"
|
import { computeAgentAreaWidth } from "./tmux-grid-constants"
|
||||||
import {
|
import {
|
||||||
canSplitPane,
|
canSplitPane,
|
||||||
findMinimalEvictions,
|
findMinimalEvictions,
|
||||||
@@ -14,6 +14,14 @@ import {
|
|||||||
import { findSpawnTarget } from "./spawn-target-finder"
|
import { findSpawnTarget } from "./spawn-target-finder"
|
||||||
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
|
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
|
||||||
|
|
||||||
|
function getInitialSplitDirection(layout?: string): "-h" | "-v" {
|
||||||
|
return layout === "main-horizontal" ? "-v" : "-h"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainLayout(layout?: string): boolean {
|
||||||
|
return layout === "main-vertical" || layout === "main-horizontal"
|
||||||
|
}
|
||||||
|
|
||||||
export function decideSpawnActions(
|
export function decideSpawnActions(
|
||||||
state: WindowState,
|
state: WindowState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -25,14 +33,13 @@ export function decideSpawnActions(
|
|||||||
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const minPaneWidth = config.agentPaneWidth
|
const agentAreaWidth = computeAgentAreaWidth(state.windowWidth, config)
|
||||||
const agentAreaWidth = Math.max(
|
const minAgentPaneWidth = config.agentPaneWidth
|
||||||
0,
|
|
||||||
state.windowWidth - state.mainPane.width - DIVIDER_SIZE,
|
|
||||||
)
|
|
||||||
const currentCount = state.agentPanes.length
|
const currentCount = state.agentPanes.length
|
||||||
|
const strictLayout = isStrictMainLayout(config.layout)
|
||||||
|
const initialSplitDirection = getInitialSplitDirection(config.layout)
|
||||||
|
|
||||||
if (agentAreaWidth < minPaneWidth && currentCount > 0) {
|
if (agentAreaWidth < minAgentPaneWidth && currentCount > 0) {
|
||||||
return {
|
return {
|
||||||
canSpawn: false,
|
canSpawn: false,
|
||||||
actions: [],
|
actions: [],
|
||||||
@@ -47,7 +54,7 @@ export function decideSpawnActions(
|
|||||||
|
|
||||||
if (currentCount === 0) {
|
if (currentCount === 0) {
|
||||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||||
if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) {
|
if (canSplitPane(virtualMainPane, initialSplitDirection, minAgentPaneWidth)) {
|
||||||
return {
|
return {
|
||||||
canSpawn: true,
|
canSpawn: true,
|
||||||
actions: [
|
actions: [
|
||||||
@@ -56,7 +63,7 @@ export function decideSpawnActions(
|
|||||||
sessionId,
|
sessionId,
|
||||||
description,
|
description,
|
||||||
targetPaneId: state.mainPane.paneId,
|
targetPaneId: state.mainPane.paneId,
|
||||||
splitDirection: "-h",
|
splitDirection: initialSplitDirection,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -64,8 +71,12 @@ export function decideSpawnActions(
|
|||||||
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) {
|
const canEvaluateSpawnTarget =
|
||||||
const spawnTarget = findSpawnTarget(state, minPaneWidth)
|
strictLayout ||
|
||||||
|
isSplittableAtCount(agentAreaWidth, currentCount, minAgentPaneWidth)
|
||||||
|
|
||||||
|
if (canEvaluateSpawnTarget) {
|
||||||
|
const spawnTarget = findSpawnTarget(state, config)
|
||||||
if (spawnTarget) {
|
if (spawnTarget) {
|
||||||
return {
|
return {
|
||||||
canSpawn: true,
|
canSpawn: true,
|
||||||
@@ -82,40 +93,43 @@ export function decideSpawnActions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth)
|
if (!strictLayout) {
|
||||||
if (minEvictions === 1 && oldestPane) {
|
const minEvictions = findMinimalEvictions(
|
||||||
return {
|
agentAreaWidth,
|
||||||
canSpawn: true,
|
currentCount,
|
||||||
actions: [
|
minAgentPaneWidth,
|
||||||
{
|
)
|
||||||
type: "replace",
|
if (minEvictions === 1 && oldestPane) {
|
||||||
paneId: oldestPane.paneId,
|
return {
|
||||||
oldSessionId: oldestMapping?.sessionId || "",
|
canSpawn: true,
|
||||||
newSessionId: sessionId,
|
actions: [
|
||||||
description,
|
{
|
||||||
},
|
type: "close",
|
||||||
],
|
paneId: oldestPane.paneId,
|
||||||
reason: "replaced oldest pane to avoid split churn",
|
sessionId: oldestMapping?.sessionId || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "spawn",
|
||||||
|
sessionId,
|
||||||
|
description,
|
||||||
|
targetPaneId: state.mainPane.paneId,
|
||||||
|
splitDirection: initialSplitDirection,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reason: "closed 1 pane to make room for split",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldestPane) {
|
if (oldestPane) {
|
||||||
return {
|
return {
|
||||||
canSpawn: true,
|
canSpawn: false,
|
||||||
actions: [
|
actions: [],
|
||||||
{
|
reason: "no split target available (defer attach)",
|
||||||
type: "replace",
|
|
||||||
paneId: oldestPane.paneId,
|
|
||||||
oldSessionId: oldestMapping?.sessionId || "",
|
|
||||||
newSessionId: sessionId,
|
|
||||||
description,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reason: "replaced oldest pane (no split possible)",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { canSpawn: false, actions: [], reason: "no pane available to replace" }
|
return { canSpawn: false, actions: [], reason: "no split target available (defer attach)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decideCloseAction(
|
export function decideCloseAction(
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
import type { SplitDirection, TmuxPaneInfo, WindowState } from "./types"
|
import type { CapacityConfig, SplitDirection, TmuxPaneInfo, WindowState } from "./types"
|
||||||
|
import { computeMainPaneWidth } from "./tmux-grid-constants"
|
||||||
import { computeGridPlan, mapPaneToSlot } from "./grid-planning"
|
import { computeGridPlan, mapPaneToSlot } from "./grid-planning"
|
||||||
import { canSplitPane, getBestSplitDirection } from "./pane-split-availability"
|
import { canSplitPane } from "./pane-split-availability"
|
||||||
import { MIN_PANE_WIDTH } from "./types"
|
|
||||||
|
|
||||||
export interface SpawnTarget {
|
export interface SpawnTarget {
|
||||||
targetPaneId: string
|
targetPaneId: string
|
||||||
splitDirection: SplitDirection
|
splitDirection: SplitDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStrictMainVertical(config: CapacityConfig): boolean {
|
||||||
|
return config.layout === "main-vertical"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainHorizontal(config: CapacityConfig): boolean {
|
||||||
|
return config.layout === "main-horizontal"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainLayout(config: CapacityConfig): boolean {
|
||||||
|
return isStrictMainVertical(config) || isStrictMainHorizontal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialSplitDirection(config: CapacityConfig): SplitDirection {
|
||||||
|
return isStrictMainHorizontal(config) ? "-v" : "-h"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrictFollowupSplitDirection(config: CapacityConfig): SplitDirection {
|
||||||
|
return isStrictMainHorizontal(config) ? "-h" : "-v"
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPanesForStrictLayout(panes: TmuxPaneInfo[], config: CapacityConfig): TmuxPaneInfo[] {
|
||||||
|
if (isStrictMainHorizontal(config)) {
|
||||||
|
return [...panes].sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
|
}
|
||||||
|
return [...panes].sort((a, b) => a.top - b.top || a.left - b.left)
|
||||||
|
}
|
||||||
|
|
||||||
function buildOccupancy(
|
function buildOccupancy(
|
||||||
agentPanes: TmuxPaneInfo[],
|
agentPanes: TmuxPaneInfo[],
|
||||||
plan: ReturnType<typeof computeGridPlan>,
|
plan: ReturnType<typeof computeGridPlan>,
|
||||||
@@ -37,16 +64,29 @@ function findFirstEmptySlot(
|
|||||||
|
|
||||||
function findSplittableTarget(
|
function findSplittableTarget(
|
||||||
state: WindowState,
|
state: WindowState,
|
||||||
minPaneWidth: number,
|
config: CapacityConfig,
|
||||||
_preferredDirection?: SplitDirection,
|
_preferredDirection?: SplitDirection,
|
||||||
): SpawnTarget | null {
|
): SpawnTarget | null {
|
||||||
if (!state.mainPane) return null
|
if (!state.mainPane) return null
|
||||||
const existingCount = state.agentPanes.length
|
const existingCount = state.agentPanes.length
|
||||||
|
const minAgentPaneWidth = config.agentPaneWidth
|
||||||
|
const initialDirection = getInitialSplitDirection(config)
|
||||||
|
|
||||||
if (existingCount === 0) {
|
if (existingCount === 0) {
|
||||||
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||||
if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) {
|
if (canSplitPane(virtualMainPane, initialDirection, minAgentPaneWidth)) {
|
||||||
return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" }
|
return { targetPaneId: state.mainPane.paneId, splitDirection: initialDirection }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrictMainLayout(config)) {
|
||||||
|
const followupDirection = getStrictFollowupSplitDirection(config)
|
||||||
|
const panesByPriority = sortPanesForStrictLayout(state.agentPanes, config)
|
||||||
|
for (const pane of panesByPriority) {
|
||||||
|
if (canSplitPane(pane, followupDirection, minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: pane.paneId, splitDirection: followupDirection }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -55,34 +95,44 @@ function findSplittableTarget(
|
|||||||
state.windowWidth,
|
state.windowWidth,
|
||||||
state.windowHeight,
|
state.windowHeight,
|
||||||
existingCount + 1,
|
existingCount + 1,
|
||||||
state.mainPane.width,
|
config,
|
||||||
minPaneWidth,
|
|
||||||
)
|
)
|
||||||
const mainPaneWidth = state.mainPane.width
|
const mainPaneWidth = computeMainPaneWidth(state.windowWidth, config)
|
||||||
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
||||||
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
||||||
|
|
||||||
const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`)
|
const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`)
|
||||||
if (leftPane && canSplitPane(leftPane, "-h", minPaneWidth)) {
|
if (
|
||||||
|
!isStrictMainVertical(config) &&
|
||||||
|
leftPane &&
|
||||||
|
canSplitPane(leftPane, "-h", minAgentPaneWidth)
|
||||||
|
) {
|
||||||
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`)
|
const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`)
|
||||||
if (abovePane && canSplitPane(abovePane, "-v", minPaneWidth)) {
|
if (abovePane && canSplitPane(abovePane, "-v", minAgentPaneWidth)) {
|
||||||
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const splittablePanes = state.agentPanes
|
const panesByPosition = [...state.agentPanes].sort(
|
||||||
.map((pane) => ({ pane, direction: getBestSplitDirection(pane, minPaneWidth) }))
|
(a, b) => a.left - b.left || a.top - b.top,
|
||||||
.filter(
|
)
|
||||||
(item): item is { pane: TmuxPaneInfo; direction: SplitDirection } =>
|
|
||||||
item.direction !== null,
|
|
||||||
)
|
|
||||||
.sort((a, b) => b.pane.width * b.pane.height - a.pane.width * a.pane.height)
|
|
||||||
|
|
||||||
const best = splittablePanes[0]
|
for (const pane of panesByPosition) {
|
||||||
if (best) {
|
if (canSplitPane(pane, "-v", minAgentPaneWidth)) {
|
||||||
return { targetPaneId: best.pane.paneId, splitDirection: best.direction }
|
return { targetPaneId: pane.paneId, splitDirection: "-v" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrictMainVertical(config)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pane of panesByPosition) {
|
||||||
|
if (canSplitPane(pane, "-h", minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: pane.paneId, splitDirection: "-h" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -90,7 +140,7 @@ function findSplittableTarget(
|
|||||||
|
|
||||||
export function findSpawnTarget(
|
export function findSpawnTarget(
|
||||||
state: WindowState,
|
state: WindowState,
|
||||||
minPaneWidth: number = MIN_PANE_WIDTH,
|
config: CapacityConfig,
|
||||||
): SpawnTarget | null {
|
): SpawnTarget | null {
|
||||||
return findSplittableTarget(state, minPaneWidth)
|
return findSplittableTarget(state, config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||||
|
import type { CapacityConfig } from "./types"
|
||||||
|
|
||||||
export const MAIN_PANE_RATIO = 0.5
|
export const MAIN_PANE_RATIO = 0.5
|
||||||
|
const DEFAULT_MAIN_PANE_SIZE = MAIN_PANE_RATIO * 100
|
||||||
export const MAX_COLS = 2
|
export const MAX_COLS = 2
|
||||||
export const MAX_ROWS = 3
|
export const MAX_ROWS = 3
|
||||||
export const MAX_GRID_SIZE = 4
|
export const MAX_GRID_SIZE = 4
|
||||||
@@ -8,3 +10,48 @@ export const DIVIDER_SIZE = 1
|
|||||||
|
|
||||||
export const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
export const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
||||||
export const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
export const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainPaneSizePercent(config?: CapacityConfig): number {
|
||||||
|
return clamp(config?.mainPaneSize ?? DEFAULT_MAIN_PANE_SIZE, 20, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMainPaneWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
config?: CapacityConfig,
|
||||||
|
): number {
|
||||||
|
const safeWindowWidth = Math.max(0, windowWidth)
|
||||||
|
if (!config) {
|
||||||
|
return Math.floor(safeWindowWidth * MAIN_PANE_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dividerWidth = DIVIDER_SIZE
|
||||||
|
const minMainPaneWidth = config?.mainPaneMinWidth ?? Math.floor(safeWindowWidth * MAIN_PANE_RATIO)
|
||||||
|
const minAgentPaneWidth = config?.agentPaneWidth ?? MIN_PANE_WIDTH
|
||||||
|
const percentageMainPaneWidth = Math.floor(
|
||||||
|
(safeWindowWidth - dividerWidth) * (getMainPaneSizePercent(config) / 100),
|
||||||
|
)
|
||||||
|
const maxMainPaneWidth = Math.max(0, safeWindowWidth - dividerWidth - minAgentPaneWidth)
|
||||||
|
|
||||||
|
return clamp(
|
||||||
|
Math.max(percentageMainPaneWidth, minMainPaneWidth),
|
||||||
|
0,
|
||||||
|
maxMainPaneWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAgentAreaWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
config?: CapacityConfig,
|
||||||
|
): number {
|
||||||
|
const safeWindowWidth = Math.max(0, windowWidth)
|
||||||
|
if (!config) {
|
||||||
|
return Math.floor(safeWindowWidth * (1 - MAIN_PANE_RATIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPaneWidth = computeMainPaneWidth(safeWindowWidth, config)
|
||||||
|
return Math.max(0, safeWindowWidth - DIVIDER_SIZE - mainPaneWidth)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface SpawnDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CapacityConfig {
|
export interface CapacityConfig {
|
||||||
|
layout?: string
|
||||||
|
mainPaneSize?: number
|
||||||
mainPaneMinWidth: number
|
mainPaneMinWidth: number
|
||||||
agentPaneWidth: number
|
agentPaneWidth: number
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/shared/tmux/tmux-utils/layout.test.ts
Normal file
44
src/shared/tmux/tmux-utils/layout.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
const spawnCalls: string[][] = []
|
||||||
|
const spawnMock = mock((args: string[]) => {
|
||||||
|
spawnCalls.push(args)
|
||||||
|
return { exited: Promise.resolve(0) }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyLayout", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
spawnCalls.length = 0
|
||||||
|
spawnMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies main-vertical with main-pane-width option", async () => {
|
||||||
|
const { applyLayout } = await import("./layout")
|
||||||
|
|
||||||
|
await applyLayout("tmux", "main-vertical", 60, { spawnCommand: spawnMock })
|
||||||
|
|
||||||
|
expect(spawnCalls).toEqual([
|
||||||
|
["tmux", "select-layout", "main-vertical"],
|
||||||
|
["tmux", "set-window-option", "main-pane-width", "60%"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies main-horizontal with main-pane-height option", async () => {
|
||||||
|
const { applyLayout } = await import("./layout")
|
||||||
|
|
||||||
|
await applyLayout("tmux", "main-horizontal", 55, { spawnCommand: spawnMock })
|
||||||
|
|
||||||
|
expect(spawnCalls).toEqual([
|
||||||
|
["tmux", "select-layout", "main-horizontal"],
|
||||||
|
["tmux", "set-window-option", "main-pane-height", "55%"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not set main pane option for non-main layouts", async () => {
|
||||||
|
const { applyLayout } = await import("./layout")
|
||||||
|
|
||||||
|
await applyLayout("tmux", "tiled", 50, { spawnCommand: spawnMock })
|
||||||
|
|
||||||
|
expect(spawnCalls).toEqual([["tmux", "select-layout", "tiled"]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,14 +2,52 @@ import { spawn } from "bun"
|
|||||||
import type { TmuxLayout } from "../../../config/schema"
|
import type { TmuxLayout } from "../../../config/schema"
|
||||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
|
||||||
|
type TmuxSpawnCommand = (
|
||||||
|
args: string[],
|
||||||
|
options: { stdout: "ignore"; stderr: "ignore" },
|
||||||
|
) => { exited: Promise<number> }
|
||||||
|
|
||||||
|
interface LayoutDeps {
|
||||||
|
spawnCommand?: TmuxSpawnCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainPaneWidthOptions {
|
||||||
|
mainPaneSize?: number
|
||||||
|
mainPaneMinWidth?: number
|
||||||
|
agentPaneMinWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMainPaneWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
options?: MainPaneWidthOptions,
|
||||||
|
): number {
|
||||||
|
const dividerWidth = 1
|
||||||
|
const sizePercent = clamp(options?.mainPaneSize ?? 50, 20, 80)
|
||||||
|
const minMainPaneWidth = options?.mainPaneMinWidth ?? 0
|
||||||
|
const minAgentPaneWidth = options?.agentPaneMinWidth ?? 0
|
||||||
|
const desiredMainPaneWidth = Math.floor(
|
||||||
|
(windowWidth - dividerWidth) * (sizePercent / 100),
|
||||||
|
)
|
||||||
|
const maxMainPaneWidth = Math.max(
|
||||||
|
0,
|
||||||
|
windowWidth - dividerWidth - minAgentPaneWidth,
|
||||||
|
)
|
||||||
|
|
||||||
|
return clamp(Math.max(desiredMainPaneWidth, minMainPaneWidth), 0, maxMainPaneWidth)
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyLayout(
|
export async function applyLayout(
|
||||||
|
tmux: string,
|
||||||
layout: TmuxLayout,
|
layout: TmuxLayout,
|
||||||
mainPaneSize: number,
|
mainPaneSize: number,
|
||||||
|
deps?: LayoutDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tmux = await getTmuxPath()
|
const spawnCommand: TmuxSpawnCommand = deps?.spawnCommand ?? spawn
|
||||||
if (!tmux) return
|
const layoutProc = spawnCommand([tmux, "select-layout", layout], {
|
||||||
|
|
||||||
const layoutProc = spawn([tmux, "select-layout", layout], {
|
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
@@ -18,7 +56,7 @@ export async function applyLayout(
|
|||||||
if (layout.startsWith("main-")) {
|
if (layout.startsWith("main-")) {
|
||||||
const dimension =
|
const dimension =
|
||||||
layout === "main-horizontal" ? "main-pane-height" : "main-pane-width"
|
layout === "main-horizontal" ? "main-pane-height" : "main-pane-width"
|
||||||
const sizeProc = spawn(
|
const sizeProc = spawnCommand(
|
||||||
[tmux, "set-window-option", dimension, `${mainPaneSize}%`],
|
[tmux, "set-window-option", dimension, `${mainPaneSize}%`],
|
||||||
{ stdout: "ignore", stderr: "ignore" },
|
{ stdout: "ignore", stderr: "ignore" },
|
||||||
)
|
)
|
||||||
@@ -29,15 +67,17 @@ export async function applyLayout(
|
|||||||
export async function enforceMainPaneWidth(
|
export async function enforceMainPaneWidth(
|
||||||
mainPaneId: string,
|
mainPaneId: string,
|
||||||
windowWidth: number,
|
windowWidth: number,
|
||||||
mainPaneSize: number,
|
mainPaneSizeOrOptions?: number | MainPaneWidthOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { log } = await import("../../logger")
|
const { log } = await import("../../logger")
|
||||||
const tmux = await getTmuxPath()
|
const tmux = await getTmuxPath()
|
||||||
if (!tmux) return
|
if (!tmux) return
|
||||||
|
|
||||||
const dividerWidth = 1
|
const options: MainPaneWidthOptions =
|
||||||
const boundedMainPaneSize = Math.max(20, Math.min(80, mainPaneSize))
|
typeof mainPaneSizeOrOptions === "number"
|
||||||
const mainWidth = Math.floor(((windowWidth - dividerWidth) * boundedMainPaneSize) / 100)
|
? { mainPaneSize: mainPaneSizeOrOptions }
|
||||||
|
: mainPaneSizeOrOptions ?? {}
|
||||||
|
const mainWidth = calculateMainPaneWidth(windowWidth, options)
|
||||||
|
|
||||||
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
|
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
@@ -49,6 +89,8 @@ export async function enforceMainPaneWidth(
|
|||||||
mainPaneId,
|
mainPaneId,
|
||||||
mainWidth,
|
mainWidth,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
mainPaneSize: boundedMainPaneSize,
|
mainPaneSize: options?.mainPaneSize,
|
||||||
|
mainPaneMinWidth: options?.mainPaneMinWidth,
|
||||||
|
agentPaneMinWidth: options?.agentPaneMinWidth,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user