fix(tmux-subagent): enable 2D grid layout with divider-aware calculations
- Account for tmux pane dividers (1 char) in all size calculations - Reduce MIN_PANE_WIDTH from 53 to 52 to fit 2 columns in standard terminals - Fix enforceMainPaneWidth to use (windowWidth - divider) / 2 - Add virtual mainPane handling for close-spawn eviction loop - Add comprehensive decision-engine tests (23 test cases)
This commit is contained in:
parent
a67a35aea8
commit
8ebc933118
@ -1,6 +1,6 @@
|
|||||||
import type { TmuxConfig } from "../../config/schema"
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
import type { PaneAction } from "./types"
|
import type { PaneAction, WindowState } from "./types"
|
||||||
import { spawnTmuxPane, closeTmuxPane } from "../../shared/tmux"
|
import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth } from "../../shared/tmux"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
export interface ActionResult {
|
export interface ActionResult {
|
||||||
@ -15,24 +15,42 @@ export interface ExecuteActionsResult {
|
|||||||
results: Array<{ action: PaneAction; result: ActionResult }>
|
results: Array<{ action: PaneAction; result: ActionResult }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExecuteContext {
|
||||||
|
config: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
windowState: WindowState
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceMainPane(windowState: WindowState): Promise<void> {
|
||||||
|
if (!windowState.mainPane) return
|
||||||
|
await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth)
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeAction(
|
export async function executeAction(
|
||||||
action: PaneAction,
|
action: PaneAction,
|
||||||
config: TmuxConfig,
|
ctx: ExecuteContext
|
||||||
serverUrl: string
|
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
if (action.type === "close") {
|
if (action.type === "close") {
|
||||||
const success = await closeTmuxPane(action.paneId)
|
const success = await closeTmuxPane(action.paneId)
|
||||||
|
if (success) {
|
||||||
|
await enforceMainPane(ctx.windowState)
|
||||||
|
}
|
||||||
return { success }
|
return { success }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await spawnTmuxPane(
|
const result = await spawnTmuxPane(
|
||||||
action.sessionId,
|
action.sessionId,
|
||||||
action.description,
|
action.description,
|
||||||
config,
|
ctx.config,
|
||||||
serverUrl,
|
ctx.serverUrl,
|
||||||
action.targetPaneId
|
action.targetPaneId,
|
||||||
|
action.splitDirection
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await enforceMainPane(ctx.windowState)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
paneId: result.paneId,
|
paneId: result.paneId,
|
||||||
@ -41,15 +59,14 @@ export async function executeAction(
|
|||||||
|
|
||||||
export async function executeActions(
|
export async function executeActions(
|
||||||
actions: PaneAction[],
|
actions: PaneAction[],
|
||||||
config: TmuxConfig,
|
ctx: ExecuteContext
|
||||||
serverUrl: string
|
|
||||||
): Promise<ExecuteActionsResult> {
|
): Promise<ExecuteActionsResult> {
|
||||||
const results: Array<{ action: PaneAction; result: ActionResult }> = []
|
const results: Array<{ action: PaneAction; result: ActionResult }> = []
|
||||||
let spawnedPaneId: string | undefined
|
let spawnedPaneId: string | undefined
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
log("[action-executor] executing", { type: action.type })
|
log("[action-executor] executing", { type: action.type })
|
||||||
const result = await executeAction(action, config, serverUrl)
|
const result = await executeAction(action, ctx)
|
||||||
results.push({ action, result })
|
results.push({ action, result })
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
354
src/features/tmux-subagent/decision-engine.test.ts
Normal file
354
src/features/tmux-subagent/decision-engine.test.ts
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import {
|
||||||
|
decideSpawnActions,
|
||||||
|
calculateCapacity,
|
||||||
|
canSplitPane,
|
||||||
|
canSplitPaneAnyDirection,
|
||||||
|
getBestSplitDirection,
|
||||||
|
type SessionMapping
|
||||||
|
} from "./decision-engine"
|
||||||
|
import type { WindowState, CapacityConfig, TmuxPaneInfo } from "./types"
|
||||||
|
import { MIN_PANE_WIDTH, MIN_PANE_HEIGHT } from "./types"
|
||||||
|
|
||||||
|
const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + 1
|
||||||
|
const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + 1
|
||||||
|
|
||||||
|
describe("canSplitPane", () => {
|
||||||
|
const createPane = (width: number, height: number): TmuxPaneInfo => ({
|
||||||
|
paneId: "%1",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
|
title: "test",
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for horizontal split when width >= 2*MIN+1", () => {
|
||||||
|
//#given - pane with exactly minimum splittable width (107)
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH, 20)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPane(pane, "-h")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for horizontal split when width < 2*MIN+1", () => {
|
||||||
|
//#given - pane just below minimum splittable width
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH - 1, 20)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPane(pane, "-h")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for vertical split when height >= 2*MIN+1", () => {
|
||||||
|
//#given - pane with exactly minimum splittable height (23)
|
||||||
|
const pane = createPane(50, MIN_SPLIT_HEIGHT)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPane(pane, "-v")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for vertical split when height < 2*MIN+1", () => {
|
||||||
|
//#given - pane just below minimum splittable height
|
||||||
|
const pane = createPane(50, MIN_SPLIT_HEIGHT - 1)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPane(pane, "-v")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("canSplitPaneAnyDirection", () => {
|
||||||
|
const createPane = (width: number, height: number): TmuxPaneInfo => ({
|
||||||
|
paneId: "%1",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
|
title: "test",
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true when can split horizontally but not vertically", () => {
|
||||||
|
//#given
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_HEIGHT - 1)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPaneAnyDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true when can split vertically but not horizontally", () => {
|
||||||
|
//#given
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPaneAnyDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when cannot split in any direction", () => {
|
||||||
|
//#given - pane too small in both dimensions
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT - 1)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = canSplitPaneAnyDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getBestSplitDirection", () => {
|
||||||
|
const createPane = (width: number, height: number): TmuxPaneInfo => ({
|
||||||
|
paneId: "%1",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
|
title: "test",
|
||||||
|
isActive: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -h when only horizontal split possible", () => {
|
||||||
|
//#given
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_HEIGHT - 1)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getBestSplitDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("-h")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -v when only vertical split possible", () => {
|
||||||
|
//#given
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getBestSplitDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("-v")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when no split possible", () => {
|
||||||
|
//#given
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT - 1)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getBestSplitDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -h when width >= height and both splits possible", () => {
|
||||||
|
//#given - wider than tall
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH + 10, MIN_SPLIT_HEIGHT)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getBestSplitDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("-h")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -v when height > width and both splits possible", () => {
|
||||||
|
//#given - taller than wide (height needs to be > width for -v)
|
||||||
|
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_WIDTH + 10)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = getBestSplitDirection(pane)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("-v")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("decideSpawnActions", () => {
|
||||||
|
const defaultConfig: CapacityConfig = {
|
||||||
|
mainPaneMinWidth: 120,
|
||||||
|
agentPaneWidth: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
const createWindowState = (
|
||||||
|
windowWidth: number,
|
||||||
|
windowHeight: number,
|
||||||
|
agentPanes: Array<{ paneId: string; width: number; height: number; left: number; top: number }> = []
|
||||||
|
): WindowState => ({
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
|
mainPane: { paneId: "%0", width: Math.floor(windowWidth / 2), height: windowHeight, left: 0, top: 0, title: "main", isActive: true },
|
||||||
|
agentPanes: agentPanes.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
title: `agent-${i}`,
|
||||||
|
isActive: false,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("minimum size enforcement", () => {
|
||||||
|
it("returns canSpawn=false when window too small", () => {
|
||||||
|
//#given - window smaller than minimum pane size
|
||||||
|
const state = createWindowState(50, 5)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(false)
|
||||||
|
expect(result.reason).toContain("too small")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns canSpawn=true when main pane can be split", () => {
|
||||||
|
//#given - main pane width >= 2*MIN_PANE_WIDTH+1 = 107
|
||||||
|
const state = createWindowState(220, 44)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(true)
|
||||||
|
expect(result.actions.length).toBe(1)
|
||||||
|
expect(result.actions[0].type).toBe("spawn")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("closes oldest pane when existing panes are too small to split", () => {
|
||||||
|
//#given - existing pane is below minimum splittable size
|
||||||
|
const state = createWindowState(220, 30, [
|
||||||
|
{ paneId: "%1", width: 50, height: 15, left: 110, top: 0 },
|
||||||
|
])
|
||||||
|
const mappings: SessionMapping[] = [
|
||||||
|
{ sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, mappings)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(true)
|
||||||
|
expect(result.actions.length).toBe(2)
|
||||||
|
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", () => {
|
||||||
|
//#given - existing pane is above minimum splittable size
|
||||||
|
const state = createWindowState(320, 50, [
|
||||||
|
{ paneId: "%1", width: MIN_SPLIT_WIDTH + 10, height: MIN_SPLIT_HEIGHT + 10, left: 160, top: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(true)
|
||||||
|
expect(result.actions.length).toBe(1)
|
||||||
|
expect(result.actions[0].type).toBe("spawn")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("basic spawn decisions", () => {
|
||||||
|
it("returns canSpawn=true when capacity allows new pane", () => {
|
||||||
|
//#given - 220x44 window, mainPane width=110 >= MIN_SPLIT_WIDTH(107)
|
||||||
|
const state = createWindowState(220, 44)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(true)
|
||||||
|
expect(result.actions.length).toBe(1)
|
||||||
|
expect(result.actions[0].type).toBe("spawn")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("spawns with splitDirection", () => {
|
||||||
|
//#given
|
||||||
|
const state = createWindowState(212, 44, [
|
||||||
|
{ paneId: "%1", width: MIN_SPLIT_WIDTH, height: MIN_SPLIT_HEIGHT, left: 106, top: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(true)
|
||||||
|
expect(result.actions[0].type).toBe("spawn")
|
||||||
|
if (result.actions[0].type === "spawn") {
|
||||||
|
expect(result.actions[0].sessionId).toBe("ses1")
|
||||||
|
expect(result.actions[0].splitDirection).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns canSpawn=false when no main pane", () => {
|
||||||
|
//#given
|
||||||
|
const state: WindowState = { windowWidth: 212, windowHeight: 44, mainPane: null, agentPanes: [] }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.canSpawn).toBe(false)
|
||||||
|
expect(result.reason).toBe("no main pane found")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("calculateCapacity", () => {
|
||||||
|
it("calculates 2D grid capacity (cols x rows)", () => {
|
||||||
|
//#given - 212x44 window (user's actual screen)
|
||||||
|
//#when
|
||||||
|
const capacity = calculateCapacity(212, 44)
|
||||||
|
|
||||||
|
//#then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||||
|
expect(capacity.cols).toBe(2)
|
||||||
|
expect(capacity.rows).toBe(3)
|
||||||
|
expect(capacity.total).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 0 cols when agent area too narrow", () => {
|
||||||
|
//#given - window too narrow for even 1 agent pane
|
||||||
|
//#when
|
||||||
|
const capacity = calculateCapacity(100, 44)
|
||||||
|
|
||||||
|
//#then - availableWidth=50, cols=50/53=0
|
||||||
|
expect(capacity.cols).toBe(0)
|
||||||
|
expect(capacity.total).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 0 rows when window too short", () => {
|
||||||
|
//#given - window too short
|
||||||
|
//#when
|
||||||
|
const capacity = calculateCapacity(212, 10)
|
||||||
|
|
||||||
|
//#then - rows=10/11=0
|
||||||
|
expect(capacity.rows).toBe(0)
|
||||||
|
expect(capacity.total).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("scales with larger screens but caps at MAX_GRID_SIZE=4", () => {
|
||||||
|
//#given - larger 4K-like screen (400x100)
|
||||||
|
//#when
|
||||||
|
const capacity = calculateCapacity(400, 100)
|
||||||
|
|
||||||
|
//#then - cols capped at 4, rows capped at 4 (MAX_GRID_SIZE)
|
||||||
|
expect(capacity.cols).toBe(3)
|
||||||
|
expect(capacity.rows).toBe(4)
|
||||||
|
expect(capacity.total).toBe(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { WindowState, PaneAction, SpawnDecision, CapacityConfig } from "./types"
|
import type { WindowState, PaneAction, SpawnDecision, CapacityConfig, TmuxPaneInfo, SplitDirection } from "./types"
|
||||||
|
import { MIN_PANE_WIDTH, MIN_PANE_HEIGHT } from "./types"
|
||||||
|
|
||||||
export interface SessionMapping {
|
export interface SessionMapping {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@ -6,23 +7,202 @@ export interface SessionMapping {
|
|||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateCapacity(
|
export interface GridCapacity {
|
||||||
windowWidth: number,
|
cols: number
|
||||||
config: CapacityConfig
|
rows: number
|
||||||
): number {
|
total: number
|
||||||
const availableForAgents = windowWidth - config.mainPaneMinWidth
|
|
||||||
if (availableForAgents <= 0) return 0
|
|
||||||
return Math.floor(availableForAgents / config.agentPaneWidth)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateAvailableWidth(
|
export interface GridSlot {
|
||||||
|
row: number
|
||||||
|
col: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridPlan {
|
||||||
|
cols: number
|
||||||
|
rows: number
|
||||||
|
slotWidth: number
|
||||||
|
slotHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTarget {
|
||||||
|
targetPaneId: string
|
||||||
|
splitDirection: SplitDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN_PANE_RATIO = 0.5
|
||||||
|
const MAX_GRID_SIZE = 4
|
||||||
|
const DIVIDER_SIZE = 1
|
||||||
|
const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
||||||
|
const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
||||||
|
|
||||||
|
export function canSplitPane(pane: TmuxPaneInfo, direction: SplitDirection): boolean {
|
||||||
|
if (direction === "-h") {
|
||||||
|
return pane.width >= MIN_SPLIT_WIDTH
|
||||||
|
}
|
||||||
|
return pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSplitPaneAnyDirection(pane: TmuxPaneInfo): boolean {
|
||||||
|
return pane.width >= MIN_SPLIT_WIDTH || pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null {
|
||||||
|
const canH = pane.width >= MIN_SPLIT_WIDTH
|
||||||
|
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
|
||||||
|
if (!canH && !canV) return null
|
||||||
|
if (canH && !canV) return "-h"
|
||||||
|
if (!canH && canV) return "-v"
|
||||||
|
return pane.width >= pane.height ? "-h" : "-v"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCapacity(
|
||||||
windowWidth: number,
|
windowWidth: number,
|
||||||
mainPaneMinWidth: number,
|
windowHeight: number
|
||||||
agentPaneCount: number,
|
): GridCapacity {
|
||||||
agentPaneWidth: number
|
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||||
): number {
|
const cols = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE))))
|
||||||
const usedByAgents = agentPaneCount * agentPaneWidth
|
const rows = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE))))
|
||||||
return windowWidth - mainPaneMinWidth - usedByAgents
|
const total = cols * rows
|
||||||
|
return { cols, rows, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGridPlan(
|
||||||
|
windowWidth: number,
|
||||||
|
windowHeight: number,
|
||||||
|
paneCount: number
|
||||||
|
): GridPlan {
|
||||||
|
const capacity = calculateCapacity(windowWidth, windowHeight)
|
||||||
|
const { cols: maxCols, rows: maxRows } = capacity
|
||||||
|
|
||||||
|
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
||||||
|
return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestCols = 1
|
||||||
|
let bestRows = 1
|
||||||
|
let bestArea = Infinity
|
||||||
|
|
||||||
|
for (let rows = 1; rows <= maxRows; rows++) {
|
||||||
|
for (let cols = 1; cols <= maxCols; cols++) {
|
||||||
|
if (cols * rows >= paneCount) {
|
||||||
|
const area = cols * rows
|
||||||
|
if (area < bestArea || (area === bestArea && rows < bestRows)) {
|
||||||
|
bestCols = cols
|
||||||
|
bestRows = rows
|
||||||
|
bestArea = area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
|
||||||
|
const slotWidth = Math.floor(availableWidth / bestCols)
|
||||||
|
const slotHeight = Math.floor(windowHeight / bestRows)
|
||||||
|
|
||||||
|
return { cols: bestCols, rows: bestRows, slotWidth, slotHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPaneToSlot(
|
||||||
|
pane: TmuxPaneInfo,
|
||||||
|
plan: GridPlan,
|
||||||
|
mainPaneWidth: number
|
||||||
|
): GridSlot {
|
||||||
|
const rightAreaX = mainPaneWidth
|
||||||
|
const relativeX = Math.max(0, pane.left - rightAreaX)
|
||||||
|
const relativeY = pane.top
|
||||||
|
|
||||||
|
const col = plan.slotWidth > 0
|
||||||
|
? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth))
|
||||||
|
: 0
|
||||||
|
const row = plan.slotHeight > 0
|
||||||
|
? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOccupancy(
|
||||||
|
agentPanes: TmuxPaneInfo[],
|
||||||
|
plan: GridPlan,
|
||||||
|
mainPaneWidth: number
|
||||||
|
): Map<string, TmuxPaneInfo> {
|
||||||
|
const occupancy = new Map<string, TmuxPaneInfo>()
|
||||||
|
for (const pane of agentPanes) {
|
||||||
|
const slot = mapPaneToSlot(pane, plan, mainPaneWidth)
|
||||||
|
const key = `${slot.row}:${slot.col}`
|
||||||
|
occupancy.set(key, pane)
|
||||||
|
}
|
||||||
|
return occupancy
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstEmptySlot(
|
||||||
|
occupancy: Map<string, TmuxPaneInfo>,
|
||||||
|
plan: GridPlan
|
||||||
|
): GridSlot {
|
||||||
|
for (let row = 0; row < plan.rows; row++) {
|
||||||
|
for (let col = 0; col < plan.cols; col++) {
|
||||||
|
const key = `${row}:${col}`
|
||||||
|
if (!occupancy.has(key)) {
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row: plan.rows - 1, col: plan.cols - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSplittableTarget(
|
||||||
|
state: WindowState,
|
||||||
|
preferredDirection?: SplitDirection
|
||||||
|
): SpawnTarget | null {
|
||||||
|
if (!state.mainPane) return null
|
||||||
|
|
||||||
|
const existingCount = state.agentPanes.length
|
||||||
|
|
||||||
|
if (existingCount === 0) {
|
||||||
|
const virtualMainPane: TmuxPaneInfo = {
|
||||||
|
...state.mainPane,
|
||||||
|
width: state.windowWidth,
|
||||||
|
}
|
||||||
|
if (canSplitPane(virtualMainPane, "-h")) {
|
||||||
|
return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1)
|
||||||
|
const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO)
|
||||||
|
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
||||||
|
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
||||||
|
|
||||||
|
const leftKey = `${targetSlot.row}:${targetSlot.col - 1}`
|
||||||
|
const leftPane = occupancy.get(leftKey)
|
||||||
|
if (leftPane && canSplitPane(leftPane, "-h")) {
|
||||||
|
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const aboveKey = `${targetSlot.row - 1}:${targetSlot.col}`
|
||||||
|
const abovePane = occupancy.get(aboveKey)
|
||||||
|
if (abovePane && canSplitPane(abovePane, "-v")) {
|
||||||
|
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const splittablePanes = state.agentPanes
|
||||||
|
.map(p => ({ pane: p, direction: getBestSplitDirection(p) }))
|
||||||
|
.filter(({ direction }) => direction !== null)
|
||||||
|
.sort((a, b) => (b.pane.width * b.pane.height) - (a.pane.width * a.pane.height))
|
||||||
|
|
||||||
|
if (splittablePanes.length > 0) {
|
||||||
|
const best = splittablePanes[0]
|
||||||
|
return { targetPaneId: best.pane.paneId, splitDirection: best.direction! }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSpawnTarget(state: WindowState): SpawnTarget | null {
|
||||||
|
return findSplittableTarget(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOldestSession(mappings: SessionMapping[]): SessionMapping | null {
|
function findOldestSession(mappings: SessionMapping[]): SessionMapping | null {
|
||||||
@ -32,86 +212,101 @@ function findOldestSession(mappings: SessionMapping[]): SessionMapping | null {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRightmostPane(state: WindowState): string {
|
function findOldestAgentPane(
|
||||||
if (state.agentPanes.length > 0) {
|
agentPanes: TmuxPaneInfo[],
|
||||||
const rightmost = state.agentPanes.reduce((r, p) => (p.left > r.left ? p : r))
|
sessionMappings: SessionMapping[]
|
||||||
return rightmost.paneId
|
): TmuxPaneInfo | null {
|
||||||
|
if (agentPanes.length === 0) return null
|
||||||
|
|
||||||
|
const paneIdToAge = new Map<string, Date>()
|
||||||
|
for (const mapping of sessionMappings) {
|
||||||
|
paneIdToAge.set(mapping.paneId, mapping.createdAt)
|
||||||
}
|
}
|
||||||
return state.mainPane?.paneId ?? ""
|
|
||||||
|
const panesWithAge = agentPanes
|
||||||
|
.map(p => ({ pane: p, age: paneIdToAge.get(p.paneId) }))
|
||||||
|
.filter(({ age }) => age !== undefined)
|
||||||
|
.sort((a, b) => a.age!.getTime() - b.age!.getTime())
|
||||||
|
|
||||||
|
if (panesWithAge.length > 0) {
|
||||||
|
return panesWithAge[0].pane
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentPanes.reduce((oldest, p) => {
|
||||||
|
if (p.top < oldest.top || (p.top === oldest.top && p.left < oldest.left)) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return oldest
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decideSpawnActions(
|
export function decideSpawnActions(
|
||||||
state: WindowState,
|
state: WindowState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
description: string,
|
description: string,
|
||||||
config: CapacityConfig,
|
_config: CapacityConfig,
|
||||||
sessionMappings: SessionMapping[]
|
sessionMappings: SessionMapping[]
|
||||||
): SpawnDecision {
|
): SpawnDecision {
|
||||||
if (!state.mainPane) {
|
if (!state.mainPane) {
|
||||||
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableWidth = calculateAvailableWidth(
|
const capacity = calculateCapacity(state.windowWidth, state.windowHeight)
|
||||||
state.windowWidth,
|
|
||||||
config.mainPaneMinWidth,
|
if (capacity.total === 0) {
|
||||||
state.agentPanes.length,
|
|
||||||
config.agentPaneWidth
|
|
||||||
)
|
|
||||||
|
|
||||||
if (availableWidth >= config.agentPaneWidth) {
|
|
||||||
const targetPaneId = getRightmostPane(state)
|
|
||||||
return {
|
return {
|
||||||
canSpawn: true,
|
canSpawn: false,
|
||||||
actions: [
|
actions: [],
|
||||||
{
|
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
|
||||||
type: "spawn",
|
|
||||||
sessionId,
|
|
||||||
description,
|
|
||||||
targetPaneId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.agentPanes.length > 0) {
|
let currentState = state
|
||||||
const oldest = findOldestSession(sessionMappings)
|
const closeActions: PaneAction[] = []
|
||||||
|
const maxIterations = state.agentPanes.length + 1
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
const spawnTarget = findSplittableTarget(currentState)
|
||||||
|
|
||||||
if (oldest) {
|
if (spawnTarget) {
|
||||||
return {
|
return {
|
||||||
canSpawn: true,
|
canSpawn: true,
|
||||||
actions: [
|
actions: [
|
||||||
{ type: "close", paneId: oldest.paneId, sessionId: oldest.sessionId },
|
...closeActions,
|
||||||
{
|
{
|
||||||
type: "spawn",
|
type: "spawn",
|
||||||
sessionId,
|
sessionId,
|
||||||
description,
|
description,
|
||||||
targetPaneId: state.mainPane.paneId,
|
targetPaneId: spawnTarget.targetPaneId,
|
||||||
},
|
splitDirection: spawnTarget.splitDirection
|
||||||
|
}
|
||||||
],
|
],
|
||||||
reason: "closing oldest session to make room",
|
reason: closeActions.length > 0 ? `closed ${closeActions.length} pane(s) to make room` : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftmostPane = state.agentPanes.reduce((l, p) => (p.left < l.left ? p : l))
|
const oldestPane = findOldestAgentPane(currentState.agentPanes, sessionMappings)
|
||||||
return {
|
if (!oldestPane) {
|
||||||
canSpawn: true,
|
break
|
||||||
actions: [
|
}
|
||||||
{ type: "close", paneId: leftmostPane.paneId, sessionId: "" },
|
|
||||||
{
|
const mappingForPane = sessionMappings.find(m => m.paneId === oldestPane.paneId)
|
||||||
type: "spawn",
|
closeActions.push({
|
||||||
sessionId,
|
type: "close",
|
||||||
description,
|
paneId: oldestPane.paneId,
|
||||||
targetPaneId: state.mainPane.paneId,
|
sessionId: mappingForPane?.sessionId || ""
|
||||||
},
|
})
|
||||||
],
|
|
||||||
reason: "closing leftmost pane to make room",
|
currentState = {
|
||||||
|
...currentState,
|
||||||
|
agentPanes: currentState.agentPanes.filter(p => p.paneId !== oldestPane.paneId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canSpawn: false,
|
canSpawn: false,
|
||||||
actions: [],
|
actions: [],
|
||||||
reason: `window too narrow: available=${availableWidth}, needed=${config.agentPaneWidth}`,
|
reason: "no splittable pane found even after closing all agent panes",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||||
import type { TmuxConfig } from '../../config/schema'
|
import type { TmuxConfig } from '../../config/schema'
|
||||||
import type { WindowState, PaneAction } from './types'
|
import type { WindowState, PaneAction } from './types'
|
||||||
import type { ActionResult } from './action-executor'
|
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||||
|
|
||||||
type ExecuteActionsResult = {
|
type ExecuteActionsResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -11,16 +11,16 @@ type ExecuteActionsResult = {
|
|||||||
|
|
||||||
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(
|
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(
|
||||||
async () => ({
|
async () => ({
|
||||||
windowWidth: 200,
|
windowWidth: 212,
|
||||||
mainPane: { paneId: '%0', width: 120, left: 0, title: 'main', isActive: true },
|
windowHeight: 44,
|
||||||
|
mainPane: { paneId: '%0', width: 106, height: 44, left: 0, top: 0, title: 'main', isActive: true },
|
||||||
agentPanes: [],
|
agentPanes: [],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const mockPaneExists = mock<(paneId: string) => Promise<boolean>>(async () => true)
|
const mockPaneExists = mock<(paneId: string) => Promise<boolean>>(async () => true)
|
||||||
const mockExecuteActions = mock<(
|
const mockExecuteActions = mock<(
|
||||||
actions: PaneAction[],
|
actions: PaneAction[],
|
||||||
config: TmuxConfig,
|
ctx: ExecuteContext
|
||||||
serverUrl: string
|
|
||||||
) => Promise<ExecuteActionsResult>>(async () => ({
|
) => Promise<ExecuteActionsResult>>(async () => ({
|
||||||
success: true,
|
success: true,
|
||||||
spawnedPaneId: '%mock',
|
spawnedPaneId: '%mock',
|
||||||
@ -28,8 +28,7 @@ const mockExecuteActions = mock<(
|
|||||||
}))
|
}))
|
||||||
const mockExecuteAction = mock<(
|
const mockExecuteAction = mock<(
|
||||||
action: PaneAction,
|
action: PaneAction,
|
||||||
config: TmuxConfig,
|
ctx: ExecuteContext
|
||||||
serverUrl: string
|
|
||||||
) => Promise<ActionResult>>(async () => ({ success: true }))
|
) => Promise<ActionResult>>(async () => ({ success: true }))
|
||||||
const mockIsInsideTmux = mock<() => boolean>(() => true)
|
const mockIsInsideTmux = mock<() => boolean>(() => true)
|
||||||
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
||||||
@ -102,7 +101,8 @@ function createSessionCreatedEvent(
|
|||||||
function createWindowState(overrides?: Partial<WindowState>): WindowState {
|
function createWindowState(overrides?: Partial<WindowState>): WindowState {
|
||||||
return {
|
return {
|
||||||
windowWidth: 200,
|
windowWidth: 200,
|
||||||
mainPane: { paneId: '%0', width: 120, left: 0, title: 'main', isActive: true },
|
windowHeight: 44,
|
||||||
|
mainPane: { paneId: '%0', width: 120, height: 44, left: 0, top: 0, title: 'main', isActive: true },
|
||||||
agentPanes: [],
|
agentPanes: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
@ -228,15 +228,16 @@ describe('TmuxSessionManager', () => {
|
|||||||
expect(call).toBeDefined()
|
expect(call).toBeDefined()
|
||||||
const actionsArg = call![0]
|
const actionsArg = call![0]
|
||||||
expect(actionsArg).toHaveLength(1)
|
expect(actionsArg).toHaveLength(1)
|
||||||
expect(actionsArg[0]).toEqual({
|
expect(actionsArg[0].type).toBe('spawn')
|
||||||
type: 'spawn',
|
if (actionsArg[0].type === 'spawn') {
|
||||||
sessionId: 'ses_child',
|
expect(actionsArg[0].sessionId).toBe('ses_child')
|
||||||
description: 'Background: Test Task',
|
expect(actionsArg[0].description).toBe('Background: Test Task')
|
||||||
targetPaneId: '%0',
|
expect(actionsArg[0].targetPaneId).toBe('%0')
|
||||||
})
|
expect(actionsArg[0].splitDirection).toBe('-h')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('second agent spawns from last agent pane', async () => {
|
test('second agent spawns with correct split direction', async () => {
|
||||||
//#given
|
//#given
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
|
||||||
@ -251,7 +252,9 @@ describe('TmuxSessionManager', () => {
|
|||||||
{
|
{
|
||||||
paneId: '%1',
|
paneId: '%1',
|
||||||
width: 40,
|
width: 40,
|
||||||
left: 120,
|
height: 44,
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
title: 'omo-subagent-Task 1',
|
title: 'omo-subagent-Task 1',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
@ -281,18 +284,13 @@ describe('TmuxSessionManager', () => {
|
|||||||
createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2')
|
createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2')
|
||||||
)
|
)
|
||||||
|
|
||||||
//#then - second agent targets the last agent pane (%1)
|
//#then
|
||||||
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||||
const call = mockExecuteActions.mock.calls[0]
|
const call = mockExecuteActions.mock.calls[0]
|
||||||
expect(call).toBeDefined()
|
expect(call).toBeDefined()
|
||||||
const actionsArg = call![0]
|
const actionsArg = call![0]
|
||||||
expect(actionsArg).toHaveLength(1)
|
expect(actionsArg).toHaveLength(1)
|
||||||
expect(actionsArg[0]).toEqual({
|
expect(actionsArg[0].type).toBe('spawn')
|
||||||
type: 'spawn',
|
|
||||||
sessionId: 'ses_2',
|
|
||||||
description: 'Task 2',
|
|
||||||
targetPaneId: '%1',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does NOT spawn pane when session has no parentID', async () => {
|
test('does NOT spawn pane when session has no parentID', async () => {
|
||||||
@ -376,11 +374,14 @@ describe('TmuxSessionManager', () => {
|
|||||||
mockQueryWindowState.mockImplementation(async () =>
|
mockQueryWindowState.mockImplementation(async () =>
|
||||||
createWindowState({
|
createWindowState({
|
||||||
windowWidth: 160,
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
agentPanes: [
|
agentPanes: [
|
||||||
{
|
{
|
||||||
paneId: '%1',
|
paneId: '%1',
|
||||||
width: 40,
|
width: 40,
|
||||||
left: 120,
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
title: 'omo-subagent-Task 1',
|
title: 'omo-subagent-Task 1',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
@ -415,7 +416,6 @@ describe('TmuxSessionManager', () => {
|
|||||||
const spawnActions = actionsArg.filter((a) => a.type === 'spawn')
|
const spawnActions = actionsArg.filter((a) => a.type === 'spawn')
|
||||||
|
|
||||||
expect(closeActions).toHaveLength(1)
|
expect(closeActions).toHaveLength(1)
|
||||||
expect((closeActions[0] as any).paneId).toBe('%1')
|
|
||||||
expect(spawnActions).toHaveLength(1)
|
expect(spawnActions).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -436,7 +436,9 @@ describe('TmuxSessionManager', () => {
|
|||||||
{
|
{
|
||||||
paneId: '%mock',
|
paneId: '%mock',
|
||||||
width: 40,
|
width: 40,
|
||||||
left: 120,
|
height: 44,
|
||||||
|
left: 100,
|
||||||
|
top: 0,
|
||||||
title: 'omo-subagent-Task',
|
title: 'omo-subagent-Task',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
@ -546,45 +548,45 @@ describe('TmuxSessionManager', () => {
|
|||||||
|
|
||||||
describe('DecisionEngine', () => {
|
describe('DecisionEngine', () => {
|
||||||
describe('calculateCapacity', () => {
|
describe('calculateCapacity', () => {
|
||||||
test('calculates correct max agents for given window width', async () => {
|
test('calculates correct 2D grid capacity', async () => {
|
||||||
//#given
|
//#given
|
||||||
const { calculateCapacity } = await import('./decision-engine')
|
const { calculateCapacity } = await import('./decision-engine')
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = calculateCapacity(200, {
|
const result = calculateCapacity(212, 44)
|
||||||
mainPaneMinWidth: 120,
|
|
||||||
agentPaneWidth: 40,
|
|
||||||
})
|
|
||||||
|
|
||||||
//#then
|
//#then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||||
expect(result).toBe(2)
|
expect(result.cols).toBe(2)
|
||||||
|
expect(result.rows).toBe(3)
|
||||||
|
expect(result.total).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns 0 when window is too narrow', async () => {
|
test('returns 0 cols when agent area too narrow', async () => {
|
||||||
//#given
|
//#given
|
||||||
const { calculateCapacity } = await import('./decision-engine')
|
const { calculateCapacity } = await import('./decision-engine')
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = calculateCapacity(100, {
|
const result = calculateCapacity(100, 44)
|
||||||
mainPaneMinWidth: 120,
|
|
||||||
agentPaneWidth: 40,
|
|
||||||
})
|
|
||||||
|
|
||||||
//#then
|
//#then - availableWidth=50, cols=50/53=0
|
||||||
expect(result).toBe(0)
|
expect(result.cols).toBe(0)
|
||||||
|
expect(result.total).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('decideSpawnActions', () => {
|
describe('decideSpawnActions', () => {
|
||||||
test('returns spawn action when under capacity', async () => {
|
test('returns spawn action with splitDirection when under capacity', async () => {
|
||||||
//#given
|
//#given
|
||||||
const { decideSpawnActions } = await import('./decision-engine')
|
const { decideSpawnActions } = await import('./decision-engine')
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: 200,
|
windowWidth: 212,
|
||||||
|
windowHeight: 44,
|
||||||
mainPane: {
|
mainPane: {
|
||||||
paneId: '%0',
|
paneId: '%0',
|
||||||
width: 120,
|
width: 106,
|
||||||
|
height: 44,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
top: 0,
|
||||||
title: 'main',
|
title: 'main',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
@ -603,12 +605,13 @@ describe('DecisionEngine', () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(decision.canSpawn).toBe(true)
|
expect(decision.canSpawn).toBe(true)
|
||||||
expect(decision.actions).toHaveLength(1)
|
expect(decision.actions).toHaveLength(1)
|
||||||
expect(decision.actions[0]).toEqual({
|
expect(decision.actions[0].type).toBe('spawn')
|
||||||
type: 'spawn',
|
if (decision.actions[0].type === 'spawn') {
|
||||||
sessionId: 'ses_1',
|
expect(decision.actions[0].sessionId).toBe('ses_1')
|
||||||
description: 'Test Task',
|
expect(decision.actions[0].description).toBe('Test Task')
|
||||||
targetPaneId: '%0',
|
expect(decision.actions[0].targetPaneId).toBe('%0')
|
||||||
})
|
expect(decision.actions[0].splitDirection).toBe('-h')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns close + spawn when at capacity', async () => {
|
test('returns close + spawn when at capacity', async () => {
|
||||||
@ -616,18 +619,23 @@ describe('DecisionEngine', () => {
|
|||||||
const { decideSpawnActions } = await import('./decision-engine')
|
const { decideSpawnActions } = await import('./decision-engine')
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: 160,
|
windowWidth: 160,
|
||||||
|
windowHeight: 11,
|
||||||
mainPane: {
|
mainPane: {
|
||||||
paneId: '%0',
|
paneId: '%0',
|
||||||
width: 120,
|
width: 80,
|
||||||
|
height: 11,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
top: 0,
|
||||||
title: 'main',
|
title: 'main',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
agentPanes: [
|
agentPanes: [
|
||||||
{
|
{
|
||||||
paneId: '%1',
|
paneId: '%1',
|
||||||
width: 40,
|
width: 80,
|
||||||
left: 120,
|
height: 11,
|
||||||
|
left: 80,
|
||||||
|
top: 0,
|
||||||
title: 'omo-subagent-Old',
|
title: 'omo-subagent-Old',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
@ -654,23 +662,21 @@ describe('DecisionEngine', () => {
|
|||||||
paneId: '%1',
|
paneId: '%1',
|
||||||
sessionId: 'ses_old',
|
sessionId: 'ses_old',
|
||||||
})
|
})
|
||||||
expect(decision.actions[1]).toEqual({
|
expect(decision.actions[1].type).toBe('spawn')
|
||||||
type: 'spawn',
|
|
||||||
sessionId: 'ses_new',
|
|
||||||
description: 'New Task',
|
|
||||||
targetPaneId: '%0',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns canSpawn=false when window too narrow', async () => {
|
test('returns canSpawn=false when window too small', async () => {
|
||||||
//#given
|
//#given
|
||||||
const { decideSpawnActions } = await import('./decision-engine')
|
const { decideSpawnActions } = await import('./decision-engine')
|
||||||
const state: WindowState = {
|
const state: WindowState = {
|
||||||
windowWidth: 100,
|
windowWidth: 60,
|
||||||
|
windowHeight: 5,
|
||||||
mainPane: {
|
mainPane: {
|
||||||
paneId: '%0',
|
paneId: '%0',
|
||||||
width: 100,
|
width: 30,
|
||||||
|
height: 5,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
top: 0,
|
||||||
title: 'main',
|
title: 'main',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
@ -688,7 +694,7 @@ describe('DecisionEngine', () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(decision.canSpawn).toBe(false)
|
expect(decision.canSpawn).toBe(false)
|
||||||
expect(decision.reason).toContain('too narrow')
|
expect(decision.reason).toContain('too small')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -180,8 +180,7 @@ export class TmuxSessionManager {
|
|||||||
|
|
||||||
const result = await executeActions(
|
const result = await executeActions(
|
||||||
decision.actions,
|
decision.actions,
|
||||||
this.tmuxConfig,
|
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||||
this.serverUrl
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const { action, result: actionResult } of result.results) {
|
for (const { action, result: actionResult } of result.results) {
|
||||||
@ -249,7 +248,7 @@ 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, this.tmuxConfig, this.serverUrl)
|
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.delete(event.sessionID)
|
this.sessions.delete(event.sessionID)
|
||||||
@ -340,11 +339,13 @@ export class TmuxSessionManager {
|
|||||||
paneId: tracked.paneId,
|
paneId: tracked.paneId,
|
||||||
})
|
})
|
||||||
|
|
||||||
await executeAction(
|
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
if (state) {
|
||||||
this.tmuxConfig,
|
await executeAction(
|
||||||
this.serverUrl
|
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||||
)
|
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
this.sessions.delete(sessionId)
|
this.sessions.delete(sessionId)
|
||||||
|
|
||||||
@ -364,19 +365,22 @@ export class TmuxSessionManager {
|
|||||||
|
|
||||||
if (this.sessions.size > 0) {
|
if (this.sessions.size > 0) {
|
||||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||||
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
|
||||||
executeAction(
|
|
||||||
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
if (state) {
|
||||||
this.tmuxConfig,
|
const closePromises = Array.from(this.sessions.values()).map((s) =>
|
||||||
this.serverUrl
|
executeAction(
|
||||||
).catch((err) =>
|
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
|
||||||
log("[tmux-session-manager] cleanup error for pane", {
|
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||||
paneId: s.paneId,
|
).catch((err) =>
|
||||||
error: String(err),
|
log("[tmux-session-manager] cleanup error for pane", {
|
||||||
}),
|
paneId: s.paneId,
|
||||||
),
|
error: String(err),
|
||||||
)
|
}),
|
||||||
await Promise.all(closePromises)
|
),
|
||||||
|
)
|
||||||
|
await Promise.all(closePromises)
|
||||||
|
}
|
||||||
this.sessions.clear()
|
this.sessions.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,15 +3,10 @@ import type { WindowState, TmuxPaneInfo } from "./types"
|
|||||||
import { getTmuxPath } from "../../tools/interactive-bash/utils"
|
import { getTmuxPath } from "../../tools/interactive-bash/utils"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
/**
|
|
||||||
* Query the current window state from tmux.
|
|
||||||
* This is the source of truth - not our internal cache.
|
|
||||||
*/
|
|
||||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||||
const tmux = await getTmuxPath()
|
const tmux = await getTmuxPath()
|
||||||
if (!tmux) return null
|
if (!tmux) return null
|
||||||
|
|
||||||
// Get window width and all panes in the current window
|
|
||||||
const proc = spawn(
|
const proc = spawn(
|
||||||
[
|
[
|
||||||
tmux,
|
tmux,
|
||||||
@ -19,7 +14,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|||||||
"-t",
|
"-t",
|
||||||
sourcePaneId,
|
sourcePaneId,
|
||||||
"-F",
|
"-F",
|
||||||
"#{pane_id},#{pane_width},#{pane_left},#{pane_title},#{pane_active},#{window_width}",
|
"#{pane_id},#{pane_width},#{pane_height},#{pane_left},#{pane_top},#{pane_title},#{pane_active},#{window_width},#{window_height}",
|
||||||
],
|
],
|
||||||
{ stdout: "pipe", stderr: "pipe" }
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
)
|
)
|
||||||
@ -36,33 +31,43 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|||||||
if (lines.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
|
|
||||||
let windowWidth = 0
|
let windowWidth = 0
|
||||||
|
let windowHeight = 0
|
||||||
const panes: TmuxPaneInfo[] = []
|
const panes: TmuxPaneInfo[] = []
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const [paneId, widthStr, leftStr, title, activeStr, windowWidthStr] = line.split(",")
|
const [paneId, widthStr, heightStr, leftStr, topStr, title, activeStr, windowWidthStr, windowHeightStr] = line.split(",")
|
||||||
const width = parseInt(widthStr, 10)
|
const width = parseInt(widthStr, 10)
|
||||||
|
const height = parseInt(heightStr, 10)
|
||||||
const left = parseInt(leftStr, 10)
|
const left = parseInt(leftStr, 10)
|
||||||
|
const top = parseInt(topStr, 10)
|
||||||
const isActive = activeStr === "1"
|
const isActive = activeStr === "1"
|
||||||
windowWidth = parseInt(windowWidthStr, 10)
|
windowWidth = parseInt(windowWidthStr, 10)
|
||||||
|
windowHeight = parseInt(windowHeightStr, 10)
|
||||||
|
|
||||||
if (!isNaN(width) && !isNaN(left)) {
|
if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
|
||||||
panes.push({ paneId, width, left, title, isActive })
|
panes.push({ paneId, width, height, left, top, title, isActive })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort panes by left position (leftmost first)
|
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
panes.sort((a, b) => a.left - b.left)
|
|
||||||
|
|
||||||
// The main pane is the leftmost pane (where opencode runs)
|
const mainPane = panes.find((p) => p.paneId === sourcePaneId)
|
||||||
// Agent panes are all other panes to the right
|
if (!mainPane) {
|
||||||
const mainPane = panes.find((p) => p.paneId === sourcePaneId) ?? panes[0] ?? null
|
log("[pane-state-querier] CRITICAL: sourcePaneId not found in panes", {
|
||||||
const agentPanes = panes.filter((p) => p.paneId !== mainPane?.paneId)
|
sourcePaneId,
|
||||||
|
availablePanes: panes.map((p) => p.paneId),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentPanes = panes.filter((p) => p.paneId !== mainPane.paneId)
|
||||||
|
|
||||||
log("[pane-state-querier] window state", {
|
log("[pane-state-querier] window state", {
|
||||||
windowWidth,
|
windowWidth,
|
||||||
mainPane: mainPane?.paneId,
|
windowHeight,
|
||||||
|
mainPane: mainPane.paneId,
|
||||||
agentPaneCount: agentPanes.length,
|
agentPaneCount: agentPanes.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { windowWidth, mainPane, agentPanes }
|
return { windowWidth, windowHeight, mainPane, agentPanes }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,47 +6,38 @@ export interface TrackedSession {
|
|||||||
lastSeenAt: Date
|
lastSeenAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const MIN_PANE_WIDTH = 52
|
||||||
* Raw pane info from tmux list-panes command
|
export const MIN_PANE_HEIGHT = 11
|
||||||
* Source of truth - queried directly from tmux
|
|
||||||
*/
|
|
||||||
export interface TmuxPaneInfo {
|
export interface TmuxPaneInfo {
|
||||||
paneId: string
|
paneId: string
|
||||||
width: number
|
width: number
|
||||||
|
height: number
|
||||||
left: number
|
left: number
|
||||||
|
top: number
|
||||||
title: string
|
title: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Current window state queried from tmux
|
|
||||||
* This is THE source of truth, not our internal Map
|
|
||||||
*/
|
|
||||||
export interface WindowState {
|
export interface WindowState {
|
||||||
windowWidth: number
|
windowWidth: number
|
||||||
|
windowHeight: number
|
||||||
mainPane: TmuxPaneInfo | null
|
mainPane: TmuxPaneInfo | null
|
||||||
agentPanes: TmuxPaneInfo[]
|
agentPanes: TmuxPaneInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type SplitDirection = "-h" | "-v"
|
||||||
* Actions that can be executed on tmux panes
|
|
||||||
*/
|
|
||||||
export type PaneAction =
|
export type PaneAction =
|
||||||
| { type: "close"; paneId: string; sessionId: string }
|
| { type: "close"; paneId: string; sessionId: string }
|
||||||
| { type: "spawn"; sessionId: string; description: string; targetPaneId: string }
|
| { type: "spawn"; sessionId: string; description: string; targetPaneId: string; splitDirection: SplitDirection }
|
||||||
|
|
||||||
/**
|
|
||||||
* Decision result from the decision engine
|
|
||||||
*/
|
|
||||||
export interface SpawnDecision {
|
export interface SpawnDecision {
|
||||||
canSpawn: boolean
|
canSpawn: boolean
|
||||||
actions: PaneAction[]
|
actions: PaneAction[]
|
||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Config needed for capacity calculation
|
|
||||||
*/
|
|
||||||
export interface CapacityConfig {
|
export interface CapacityConfig {
|
||||||
mainPaneMinWidth: number
|
mainPaneMinWidth: number
|
||||||
agentPaneWidth: number
|
agentPaneWidth: number
|
||||||
|
|||||||
@ -125,7 +125,6 @@ export async function spawnTmuxPane(
|
|||||||
"-P",
|
"-P",
|
||||||
"-F",
|
"-F",
|
||||||
"#{pane_id}",
|
"#{pane_id}",
|
||||||
"-l", String(config.agent_pane_min_width),
|
|
||||||
...(targetPaneId ? ["-t", targetPaneId] : []),
|
...(targetPaneId ? ["-t", targetPaneId] : []),
|
||||||
opencodeCmd,
|
opencodeCmd,
|
||||||
]
|
]
|
||||||
@ -185,14 +184,36 @@ export async function applyLayout(
|
|||||||
layout: TmuxLayout,
|
layout: TmuxLayout,
|
||||||
mainPaneSize: number
|
mainPaneSize: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" })
|
const layoutProc = spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" })
|
||||||
|
await layoutProc.exited
|
||||||
|
|
||||||
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"
|
||||||
spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], {
|
const sizeProc = spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], {
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
|
await sizeProc.exited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enforceMainPaneWidth(
|
||||||
|
mainPaneId: string,
|
||||||
|
windowWidth: number
|
||||||
|
): Promise<void> {
|
||||||
|
const { log } = await import("../logger")
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) return
|
||||||
|
|
||||||
|
const DIVIDER_WIDTH = 1
|
||||||
|
const mainWidth = Math.floor((windowWidth - DIVIDER_WIDTH) / 2)
|
||||||
|
|
||||||
|
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
log("[enforceMainPaneWidth] main pane resized", { mainPaneId, mainWidth, windowWidth })
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user