diff --git a/src/features/tmux-subagent/action-executor.ts b/src/features/tmux-subagent/action-executor.ts index 02233bb228..060643edd3 100644 --- a/src/features/tmux-subagent/action-executor.ts +++ b/src/features/tmux-subagent/action-executor.ts @@ -1,6 +1,14 @@ import type { TmuxConfig } from "../../config/schema" import type { PaneAction, WindowState } from "./types" -import { spawnTmuxPane, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane } from "../../shared/tmux" +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" export interface ActionResult { @@ -19,11 +27,40 @@ export interface ExecuteContext { config: TmuxConfig serverUrl: string windowState: WindowState + sourcePaneId?: string } -async function enforceMainPane(windowState: WindowState): Promise { +async function enforceMainPane( + windowState: WindowState, + config: TmuxConfig, +): Promise { if (!windowState.mainPane) return - await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth) + 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 { + 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( @@ -33,7 +70,7 @@ export async function executeAction( if (action.type === "close") { const success = await closeTmuxPane(action.paneId) if (success) { - await enforceMainPane(ctx.windowState) + await enforceLayoutAndMainPane(ctx) } return { success } } @@ -46,6 +83,9 @@ export async function executeAction( ctx.config, ctx.serverUrl ) + if (result.success) { + await enforceLayoutAndMainPane(ctx) + } return { success: result.success, paneId: result.paneId, @@ -62,7 +102,7 @@ export async function executeAction( ) if (result.success) { - await enforceMainPane(ctx.windowState) + await enforceLayoutAndMainPane(ctx) } return { diff --git a/src/features/tmux-subagent/decision-engine.test.ts b/src/features/tmux-subagent/decision-engine.test.ts index c0fa2ccc40..766dd30cd8 100644 --- a/src/features/tmux-subagent/decision-engine.test.ts +++ b/src/features/tmux-subagent/decision-engine.test.ts @@ -5,6 +5,7 @@ import { canSplitPane, canSplitPaneAnyDirection, getBestSplitDirection, + findSpawnTarget, type SessionMapping } from "./decision-engine" import type { WindowState, CapacityConfig, TmuxPaneInfo } from "./types" @@ -228,6 +229,29 @@ describe("decideSpawnActions", () => { 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("closes oldest pane when existing panes are too small to split", () => { // given - existing pane is below minimum splittable size const state = createWindowState(220, 30, [ @@ -306,6 +330,64 @@ describe("decideSpawnActions", () => { expect(result.canSpawn).toBe(false) 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" }) }) }) diff --git a/src/features/tmux-subagent/grid-planning.ts b/src/features/tmux-subagent/grid-planning.ts index 037b14bc1b..e0282e9cff 100644 --- a/src/features/tmux-subagent/grid-planning.ts +++ b/src/features/tmux-subagent/grid-planning.ts @@ -1,9 +1,9 @@ import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types" -import type { TmuxPaneInfo } from "./types" +import type { CapacityConfig, TmuxPaneInfo } from "./types" import { DIVIDER_SIZE, - MAIN_PANE_RATIO, MAX_GRID_SIZE, + computeAgentAreaWidth, } from "./tmux-grid-constants" export interface GridCapacity { @@ -24,12 +24,32 @@ export interface GridPlan { slotHeight: number } +type CapacityOptions = CapacityConfig | number | undefined + +function resolveMinPaneWidth(options?: CapacityOptions): number { + if (typeof options === "number") { + return Math.max(1, options) + } + if (options && typeof options.agentPaneWidth === "number") { + return Math.max(1, options.agentPaneWidth) + } + 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( windowWidth: number, windowHeight: number, - minPaneWidth: number = MIN_PANE_WIDTH, + options?: CapacityOptions, ): GridCapacity { - const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) + const availableWidth = resolveAgentAreaWidth(windowWidth, options) + const minPaneWidth = resolveMinPaneWidth(options) const cols = Math.min( MAX_GRID_SIZE, Math.max( @@ -55,8 +75,9 @@ export function computeGridPlan( windowWidth: number, windowHeight: number, paneCount: number, + options?: CapacityOptions, ): GridPlan { - const capacity = calculateCapacity(windowWidth, windowHeight) + const capacity = calculateCapacity(windowWidth, windowHeight, options) const { cols: maxCols, rows: maxRows } = capacity if (maxCols === 0 || maxRows === 0 || paneCount === 0) { @@ -79,7 +100,7 @@ export function computeGridPlan( } } - const availableWidth = Math.floor(windowWidth * (1 - MAIN_PANE_RATIO)) + const availableWidth = resolveAgentAreaWidth(windowWidth, options) const slotWidth = Math.floor(availableWidth / bestCols) const slotHeight = Math.floor(windowHeight / bestRows) diff --git a/src/features/tmux-subagent/layout-config.test.ts b/src/features/tmux-subagent/layout-config.test.ts new file mode 100644 index 0000000000..dac7979f69 --- /dev/null +++ b/src/features/tmux-subagent/layout-config.test.ts @@ -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", + }) + }) +}) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 954a9d8b20..3505d3c61a 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -155,7 +155,15 @@ describe('TmuxSessionManager', () => { // given mockIsInsideTmux.mockReturnValue(true) 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 = { enabled: true, layout: 'main-vertical', @@ -175,7 +183,13 @@ describe('TmuxSessionManager', () => { // given mockIsInsideTmux.mockReturnValue(false) const { TmuxSessionManager } = await import('./manager') - const ctx = createMockContext() + const ctx = createMockContext({ + sessionStatusResult: { + data: { + ses_once: { type: 'running' }, + }, + }, + }) const config: TmuxConfig = { enabled: true, layout: 'main-vertical', @@ -385,7 +399,7 @@ describe('TmuxSessionManager', () => { 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 mockIsInsideTmux.mockReturnValue(true) mockQueryWindowState.mockImplementation(async () => @@ -422,13 +436,224 @@ describe('TmuxSessionManager', () => { createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task') ) - // then - with small window, replace action is used instead of close+spawn - expect(mockExecuteActions).toHaveBeenCalledTimes(1) - const call = mockExecuteActions.mock.calls[0] - expect(call).toBeDefined() - const actionsArg = call![0] - expect(actionsArg).toHaveLength(1) - expect(actionsArg[0].type).toBe('replace') + // then - with small window, manager defers instead of replacing + expect(mockExecuteActions).toHaveBeenCalledTimes(0) + expect((manager as any).deferredQueue).toEqual(['ses_new']) + }) + + test('keeps deferred queue idempotent for duplicate session.created events', 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) + + // 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) }) }) @@ -842,7 +1067,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 const { decideSpawnActions } = await import('./decision-engine') const state: WindowState = { @@ -882,10 +1107,10 @@ describe('DecisionEngine', () => { sessionMappings ) - // then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used - expect(decision.canSpawn).toBe(true) - expect(decision.actions).toHaveLength(1) - expect(decision.actions[0].type).toBe('replace') + // then - agent area (80) < MIN_SPLIT_WIDTH (105), so attach is deferred + expect(decision.canSpawn).toBe(false) + expect(decision.actions).toHaveLength(0) + expect(decision.reason).toContain('defer') }) test('returns canSpawn=false when window too small', async () => { diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 5bd8d6e8cc..ee85769856 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -21,6 +21,12 @@ interface SessionCreatedEvent { properties?: { info?: { id?: string; parentID?: string; title?: string } } } +interface DeferredSession { + sessionId: string + title: string + queuedAt: Date +} + export interface TmuxUtilDeps { isInsideTmux: () => boolean getCurrentPaneId: () => string | undefined @@ -57,6 +63,11 @@ export class TmuxSessionManager { private sourcePaneId: string | undefined private sessions = new Map() private pendingSessions = new Set() + private spawnQueue: Promise = Promise.resolve() + private deferredSessions = new Map() + private deferredQueue: string[] = [] + private deferredAttachInterval?: ReturnType + private deferredAttachTickScheduled = false private deps: TmuxUtilDeps private pollingManager: TmuxPollingManager constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { @@ -84,6 +95,8 @@ export class TmuxSessionManager { private getCapacityConfig(): CapacityConfig { return { + layout: this.tmuxConfig.layout, + mainPaneSize: this.tmuxConfig.main_pane_size, mainPaneMinWidth: this.tmuxConfig.main_pane_min_width, agentPaneWidth: this.tmuxConfig.agent_pane_min_width, } @@ -97,6 +110,136 @@ 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(() => { + if (this.deferredAttachTickScheduled) return + this.deferredAttachTickScheduled = true + void this.enqueueSpawn(async () => { + try { + await this.tryAttachDeferredSession() + } finally { + this.deferredAttachTickScheduled = false + } + }) + }, 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 + this.deferredAttachTickScheduled = false + log("[tmux-session-manager] deferred attach polling stopped") + } + + private async tryAttachDeferredSession(): Promise { + 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 { const startTime = Date.now() @@ -153,7 +296,11 @@ export class TmuxSessionManager { const sessionId = info.id 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 }) return } @@ -162,15 +309,17 @@ export class TmuxSessionManager { log("[tmux-session-manager] no source pane id") return } + const sourcePaneId = this.sourcePaneId this.pendingSessions.add(sessionId) - try { - const state = await queryWindowState(this.sourcePaneId) - if (!state) { - log("[tmux-session-manager] failed to query window state") - return - } + await this.enqueueSpawn(async () => { + try { + const state = await queryWindowState(sourcePaneId) + if (!state) { + log("[tmux-session-manager] failed to query window state") + return + } log("[tmux-session-manager] window state queried", { windowWidth: state.windowWidth, @@ -179,13 +328,13 @@ export class TmuxSessionManager { agentPanes: state.agentPanes.map((p) => p.paneId), }) - const decision = decideSpawnActions( - state, - sessionId, - title, - this.getCapacityConfig(), - this.getSessionMappings() - ) + const decision = decideSpawnActions( + state, + sessionId, + title, + this.getCapacityConfig(), + this.getSessionMappings() + ) log("[tmux-session-manager] spawn decision", { canSpawn: decision.canSpawn, @@ -198,75 +347,96 @@ export class TmuxSessionManager { }), }) - if (!decision.canSpawn) { - log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) - return - } + if (!decision.canSpawn) { + log("[tmux-session-manager] cannot spawn", { reason: decision.reason }) + this.enqueueDeferredSession(sessionId, title) + return + } - const result = await executeActions( - decision.actions, - { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } - ) + const result = await executeActions( + decision.actions, + { + config: this.tmuxConfig, + 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, - }) + 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 (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 (result.success && result.spawnedPaneId) { + const sessionReady = await this.waitForSessionReady(sessionId) - if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + 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, + 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, + })), }) } - - const now = Date.now() - 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) } - } finally { - this.pendingSessions.delete(sessionId) - } + }) + } + + private async enqueueSpawn(run: () => Promise): Promise { + 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 { if (!this.isEnabled()) return if (!this.sourcePaneId) return + this.removeDeferredSession(event.sessionID) + const tracked = this.sessions.get(event.sessionID) if (!tracked) return @@ -280,7 +450,12 @@ export class TmuxSessionManager { const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings()) 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) @@ -304,7 +479,12 @@ export class TmuxSessionManager { if (state) { await executeAction( { 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, + } ) } @@ -322,6 +502,9 @@ export class TmuxSessionManager { } async cleanup(): Promise { + this.stopDeferredAttachLoop() + this.deferredQueue = [] + this.deferredSessions.clear() this.pollingManager.stopPolling() if (this.sessions.size > 0) { @@ -332,7 +515,12 @@ export class TmuxSessionManager { const closePromises = Array.from(this.sessions.values()).map((s) => executeAction( { 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) => log("[tmux-session-manager] cleanup error for pane", { paneId: s.paneId, diff --git a/src/features/tmux-subagent/pane-split-availability.ts b/src/features/tmux-subagent/pane-split-availability.ts index 65f8524740..174335cffb 100644 --- a/src/features/tmux-subagent/pane-split-availability.ts +++ b/src/features/tmux-subagent/pane-split-availability.ts @@ -1,15 +1,15 @@ -import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types" import type { SplitDirection, TmuxPaneInfo } from "./types" import { DIVIDER_SIZE, MAX_COLS, MAX_ROWS, MIN_SPLIT_HEIGHT, - MIN_SPLIT_WIDTH, } from "./tmux-grid-constants" +import { MIN_PANE_WIDTH } from "./types" -function minSplitWidthFor(minPaneWidth: number): number { - return 2 * minPaneWidth + DIVIDER_SIZE +function getMinSplitWidth(minPaneWidth?: number): number { + const width = Math.max(1, minPaneWidth ?? MIN_PANE_WIDTH) + return 2 * width + DIVIDER_SIZE } export function getColumnCount(paneCount: number): number { @@ -26,16 +26,16 @@ export function getColumnWidth(agentAreaWidth: number, paneCount: number): numbe export function isSplittableAtCount( agentAreaWidth: number, paneCount: number, - minPaneWidth: number = MIN_PANE_WIDTH, + minPaneWidth?: number, ): boolean { const columnWidth = getColumnWidth(agentAreaWidth, paneCount) - return columnWidth >= minSplitWidthFor(minPaneWidth) + return columnWidth >= getMinSplitWidth(minPaneWidth) } export function findMinimalEvictions( agentAreaWidth: number, currentCount: number, - minPaneWidth: number = MIN_PANE_WIDTH, + minPaneWidth?: number, ): number | null { for (let k = 1; k <= currentCount; k++) { if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) { @@ -48,20 +48,26 @@ export function findMinimalEvictions( export function canSplitPane( pane: TmuxPaneInfo, direction: SplitDirection, - minPaneWidth: number = MIN_PANE_WIDTH, + minPaneWidth?: number, ): boolean { if (direction === "-h") { - return pane.width >= minSplitWidthFor(minPaneWidth) + return pane.width >= getMinSplitWidth(minPaneWidth) } 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 canSplitPaneAnyDirection( + pane: TmuxPaneInfo, + minPaneWidth?: number, +): boolean { + return pane.width >= getMinSplitWidth(minPaneWidth) || pane.height >= MIN_SPLIT_HEIGHT } -export function getBestSplitDirection(pane: TmuxPaneInfo): SplitDirection | null { - const canH = pane.width >= MIN_SPLIT_WIDTH +export function getBestSplitDirection( + pane: TmuxPaneInfo, + minPaneWidth?: number, +): SplitDirection | null { + const canH = pane.width >= getMinSplitWidth(minPaneWidth) const canV = pane.height >= MIN_SPLIT_HEIGHT if (!canH && !canV) return null diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index 988cfaf028..28d5158a44 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -14,7 +14,7 @@ export async function queryWindowState(sourcePaneId: string): Promise a.left - b.left || a.top - b.top) - const mainPane = panes.find((p) => p.paneId === sourcePaneId) + const mainPane = panes.reduce((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) { - log("[pane-state-querier] CRITICAL: sourcePaneId not found in panes", { + log("[pane-state-querier] CRITICAL: failed to determine main pane", { sourcePaneId, availablePanes: panes.map((p) => p.paneId), }) diff --git a/src/features/tmux-subagent/spawn-action-decider.ts b/src/features/tmux-subagent/spawn-action-decider.ts index 9a2f71ff58..dd8dab2962 100644 --- a/src/features/tmux-subagent/spawn-action-decider.ts +++ b/src/features/tmux-subagent/spawn-action-decider.ts @@ -5,7 +5,7 @@ import type { TmuxPaneInfo, WindowState, } from "./types" -import { MAIN_PANE_RATIO } from "./tmux-grid-constants" +import { computeAgentAreaWidth } from "./tmux-grid-constants" import { canSplitPane, findMinimalEvictions, @@ -14,6 +14,14 @@ import { import { findSpawnTarget } from "./spawn-target-finder" 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( state: WindowState, sessionId: string, @@ -25,11 +33,13 @@ export function decideSpawnActions( return { canSpawn: false, actions: [], reason: "no main pane found" } } - const minPaneWidth = config.agentPaneWidth - const agentAreaWidth = Math.floor(state.windowWidth * (1 - MAIN_PANE_RATIO)) + const agentAreaWidth = computeAgentAreaWidth(state.windowWidth, config) + const minAgentPaneWidth = config.agentPaneWidth const currentCount = state.agentPanes.length + const strictLayout = isStrictMainLayout(config.layout) + const initialSplitDirection = getInitialSplitDirection(config.layout) - if (agentAreaWidth < minPaneWidth) { + if (agentAreaWidth < minAgentPaneWidth) { return { canSpawn: false, actions: [], @@ -44,7 +54,7 @@ export function decideSpawnActions( if (currentCount === 0) { const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } - if (canSplitPane(virtualMainPane, "-h", minPaneWidth)) { + if (canSplitPane(virtualMainPane, initialSplitDirection, minAgentPaneWidth)) { return { canSpawn: true, actions: [ @@ -53,7 +63,7 @@ export function decideSpawnActions( sessionId, description, targetPaneId: state.mainPane.paneId, - splitDirection: "-h", + splitDirection: initialSplitDirection, }, ], } @@ -61,8 +71,12 @@ export function decideSpawnActions( return { canSpawn: false, actions: [], reason: "mainPane too small to split" } } - if (isSplittableAtCount(agentAreaWidth, currentCount, minPaneWidth)) { - const spawnTarget = findSpawnTarget(state) + const canEvaluateSpawnTarget = + strictLayout || + isSplittableAtCount(agentAreaWidth, currentCount, minAgentPaneWidth) + + if (canEvaluateSpawnTarget) { + const spawnTarget = findSpawnTarget(state, config) if (spawnTarget) { return { canSpawn: true, @@ -79,45 +93,43 @@ export function decideSpawnActions( } } - const minEvictions = findMinimalEvictions(agentAreaWidth, currentCount, minPaneWidth) - if (minEvictions === 1 && oldestPane) { - return { - canSpawn: true, - actions: [ - { - type: "close", - paneId: oldestPane.paneId, - sessionId: oldestMapping?.sessionId || "", - }, - { - type: "spawn", - sessionId, - description, - targetPaneId: state.mainPane.paneId, - splitDirection: "-h", - }, - ], - reason: "closed 1 pane to make room for split", + if (!strictLayout) { + const minEvictions = findMinimalEvictions( + agentAreaWidth, + currentCount, + minAgentPaneWidth, + ) + if (minEvictions === 1 && oldestPane) { + return { + canSpawn: true, + actions: [ + { + type: "close", + paneId: oldestPane.paneId, + sessionId: oldestMapping?.sessionId || "", + }, + { + type: "spawn", + sessionId, + description, + targetPaneId: state.mainPane.paneId, + splitDirection: initialSplitDirection, + }, + ], + reason: "closed 1 pane to make room for split", + } } } if (oldestPane) { return { - canSpawn: true, - actions: [ - { - type: "replace", - paneId: oldestPane.paneId, - oldSessionId: oldestMapping?.sessionId || "", - newSessionId: sessionId, - description, - }, - ], - reason: "replaced oldest pane (no split possible)", + canSpawn: false, + actions: [], + reason: "no split target available (defer attach)", } } - return { canSpawn: false, actions: [], reason: "no pane available to replace" } + return { canSpawn: false, actions: [], reason: "no split target available (defer attach)" } } export function decideCloseAction( diff --git a/src/features/tmux-subagent/spawn-target-finder.ts b/src/features/tmux-subagent/spawn-target-finder.ts index 592f4c2fb3..f89b3f255e 100644 --- a/src/features/tmux-subagent/spawn-target-finder.ts +++ b/src/features/tmux-subagent/spawn-target-finder.ts @@ -1,13 +1,40 @@ -import type { SplitDirection, TmuxPaneInfo, WindowState } from "./types" -import { MAIN_PANE_RATIO } from "./tmux-grid-constants" +import type { CapacityConfig, SplitDirection, TmuxPaneInfo, WindowState } from "./types" +import { computeMainPaneWidth } from "./tmux-grid-constants" import { computeGridPlan, mapPaneToSlot } from "./grid-planning" -import { canSplitPane, getBestSplitDirection } from "./pane-split-availability" +import { canSplitPane } from "./pane-split-availability" export interface SpawnTarget { targetPaneId: string 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( agentPanes: TmuxPaneInfo[], plan: ReturnType, @@ -37,50 +64,83 @@ function findFirstEmptySlot( function findSplittableTarget( state: WindowState, + config: CapacityConfig, _preferredDirection?: SplitDirection, ): SpawnTarget | null { if (!state.mainPane) return null const existingCount = state.agentPanes.length + const minAgentPaneWidth = config.agentPaneWidth + const initialDirection = getInitialSplitDirection(config) if (existingCount === 0) { const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth } - if (canSplitPane(virtualMainPane, "-h")) { - return { targetPaneId: state.mainPane.paneId, splitDirection: "-h" } + if (canSplitPane(virtualMainPane, initialDirection, minAgentPaneWidth)) { + 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 } - const plan = computeGridPlan(state.windowWidth, state.windowHeight, existingCount + 1) - const mainPaneWidth = Math.floor(state.windowWidth * MAIN_PANE_RATIO) + const plan = computeGridPlan( + state.windowWidth, + state.windowHeight, + existingCount + 1, + config, + ) + const mainPaneWidth = computeMainPaneWidth(state.windowWidth, config) const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth) const targetSlot = findFirstEmptySlot(occupancy, plan) const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`) - if (leftPane && canSplitPane(leftPane, "-h")) { + if ( + !isStrictMainVertical(config) && + leftPane && + canSplitPane(leftPane, "-h", minAgentPaneWidth) + ) { return { targetPaneId: leftPane.paneId, splitDirection: "-h" } } const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`) - if (abovePane && canSplitPane(abovePane, "-v")) { + if (abovePane && canSplitPane(abovePane, "-v", minAgentPaneWidth)) { return { targetPaneId: abovePane.paneId, splitDirection: "-v" } } - const splittablePanes = state.agentPanes - .map((pane) => ({ pane, direction: getBestSplitDirection(pane) })) - .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] - if (best) { - return { targetPaneId: best.pane.paneId, splitDirection: best.direction } + const panesByPosition = [...state.agentPanes].sort( + (a, b) => a.left - b.left || a.top - b.top, + ) + + for (const pane of panesByPosition) { + if (canSplitPane(pane, "-v", minAgentPaneWidth)) { + 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 } -export function findSpawnTarget(state: WindowState): SpawnTarget | null { - return findSplittableTarget(state) +export function findSpawnTarget( + state: WindowState, + config: CapacityConfig, +): SpawnTarget | null { + return findSplittableTarget(state, config) } diff --git a/src/features/tmux-subagent/tmux-grid-constants.ts b/src/features/tmux-subagent/tmux-grid-constants.ts index 778c5e3ae6..7fe92912a8 100644 --- a/src/features/tmux-subagent/tmux-grid-constants.ts +++ b/src/features/tmux-subagent/tmux-grid-constants.ts @@ -1,6 +1,8 @@ import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types" +import type { CapacityConfig } from "./types" export const MAIN_PANE_RATIO = 0.5 +const DEFAULT_MAIN_PANE_SIZE = MAIN_PANE_RATIO * 100 export const MAX_COLS = 2 export const MAX_ROWS = 3 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_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) +} diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts index 6af50393ef..86ea373551 100644 --- a/src/features/tmux-subagent/types.ts +++ b/src/features/tmux-subagent/types.ts @@ -43,6 +43,8 @@ export interface SpawnDecision { } export interface CapacityConfig { + layout?: string + mainPaneSize?: number mainPaneMinWidth: number agentPaneWidth: number } diff --git a/src/shared/tmux/tmux-utils/layout.test.ts b/src/shared/tmux/tmux-utils/layout.test.ts new file mode 100644 index 0000000000..115e16c3e7 --- /dev/null +++ b/src/shared/tmux/tmux-utils/layout.test.ts @@ -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"]]) + }) +}) diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts index d7900ff736..597c2da2b2 100644 --- a/src/shared/tmux/tmux-utils/layout.ts +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -2,12 +2,52 @@ import { spawn } from "bun" import type { TmuxLayout } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +type TmuxSpawnCommand = ( + args: string[], + options: { stdout: "ignore"; stderr: "ignore" }, +) => { exited: Promise } + +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( tmux: string, layout: TmuxLayout, mainPaneSize: number, + deps?: LayoutDeps, ): Promise { - const layoutProc = spawn([tmux, "select-layout", layout], { + const spawnCommand: TmuxSpawnCommand = deps?.spawnCommand ?? spawn + const layoutProc = spawnCommand([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore", }) @@ -16,7 +56,7 @@ export async function applyLayout( if (layout.startsWith("main-")) { const dimension = layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" - const sizeProc = spawn( + const sizeProc = spawnCommand( [tmux, "set-window-option", dimension, `${mainPaneSize}%`], { stdout: "ignore", stderr: "ignore" }, ) @@ -27,13 +67,13 @@ export async function applyLayout( export async function enforceMainPaneWidth( mainPaneId: string, windowWidth: number, + options?: MainPaneWidthOptions, ): Promise { const { log } = await import("../../logger") const tmux = await getTmuxPath() if (!tmux) return - const dividerWidth = 1 - const mainWidth = Math.floor((windowWidth - dividerWidth) / 2) + const mainWidth = calculateMainPaneWidth(windowWidth, options) const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { stdout: "ignore", @@ -45,5 +85,8 @@ export async function enforceMainPaneWidth( mainPaneId, mainWidth, windowWidth, + mainPaneSize: options?.mainPaneSize, + mainPaneMinWidth: options?.mainPaneMinWidth, + agentPaneMinWidth: options?.agentPaneMinWidth, }) }