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:
justsisyphus
2026-01-26 15:11:16 +09:00
parent 5f1dbe8ffd
commit 4eca083387
8 changed files with 789 additions and 196 deletions
+260 -65
View File
@@ -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 {
sessionId: string
@@ -6,23 +7,202 @@ export interface SessionMapping {
createdAt: Date
}
export function calculateCapacity(
windowWidth: number,
config: CapacityConfig
): number {
const availableForAgents = windowWidth - config.mainPaneMinWidth
if (availableForAgents <= 0) return 0
return Math.floor(availableForAgents / config.agentPaneWidth)
export interface GridCapacity {
cols: number
rows: number
total: number
}
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,
mainPaneMinWidth: number,
agentPaneCount: number,
agentPaneWidth: number
): number {
const usedByAgents = agentPaneCount * agentPaneWidth
return windowWidth - mainPaneMinWidth - usedByAgents
windowHeight: number
): GridCapacity {
const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO))
const cols = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((availableWidth + DIVIDER_SIZE) / (MIN_PANE_WIDTH + DIVIDER_SIZE))))
const rows = Math.min(MAX_GRID_SIZE, Math.max(0, Math.floor((windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE))))
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 {
@@ -32,86 +212,101 @@ function findOldestSession(mappings: SessionMapping[]): SessionMapping | null {
)
}
function getRightmostPane(state: WindowState): string {
if (state.agentPanes.length > 0) {
const rightmost = state.agentPanes.reduce((r, p) => (p.left > r.left ? p : r))
return rightmost.paneId
function findOldestAgentPane(
agentPanes: TmuxPaneInfo[],
sessionMappings: SessionMapping[]
): 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(
state: WindowState,
sessionId: string,
description: string,
config: CapacityConfig,
_config: CapacityConfig,
sessionMappings: SessionMapping[]
): SpawnDecision {
if (!state.mainPane) {
return { canSpawn: false, actions: [], reason: "no main pane found" }
}
const availableWidth = calculateAvailableWidth(
state.windowWidth,
config.mainPaneMinWidth,
state.agentPanes.length,
config.agentPaneWidth
)
if (availableWidth >= config.agentPaneWidth) {
const targetPaneId = getRightmostPane(state)
const capacity = calculateCapacity(state.windowWidth, state.windowHeight)
if (capacity.total === 0) {
return {
canSpawn: true,
actions: [
{
type: "spawn",
sessionId,
description,
targetPaneId,
},
],
canSpawn: false,
actions: [],
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
}
}
if (state.agentPanes.length > 0) {
const oldest = findOldestSession(sessionMappings)
let currentState = state
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 {
canSpawn: true,
actions: [
{ type: "close", paneId: oldest.paneId, sessionId: oldest.sessionId },
{
type: "spawn",
sessionId,
description,
targetPaneId: state.mainPane.paneId,
},
...closeActions,
{
type: "spawn",
sessionId,
description,
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))
return {
canSpawn: true,
actions: [
{ type: "close", paneId: leftmostPane.paneId, sessionId: "" },
{
type: "spawn",
sessionId,
description,
targetPaneId: state.mainPane.paneId,
},
],
reason: "closing leftmost pane to make room",
const oldestPane = findOldestAgentPane(currentState.agentPanes, sessionMappings)
if (!oldestPane) {
break
}
const mappingForPane = sessionMappings.find(m => m.paneId === oldestPane.paneId)
closeActions.push({
type: "close",
paneId: oldestPane.paneId,
sessionId: mappingForPane?.sessionId || ""
})
currentState = {
...currentState,
agentPanes: currentState.agentPanes.filter(p => p.paneId !== oldestPane.paneId)
}
}
return {
canSpawn: false,
actions: [],
reason: `window too narrow: available=${availableWidth}, needed=${config.agentPaneWidth}`,
reason: "no splittable pane found even after closing all agent panes",
}
}