diff --git a/src/features/tmux-subagent/pane-state-parser.ts b/src/features/tmux-subagent/pane-state-parser.ts new file mode 100644 index 0000000000..c9aa1ecc9e --- /dev/null +++ b/src/features/tmux-subagent/pane-state-parser.ts @@ -0,0 +1,125 @@ +import type { TmuxPaneInfo } from "./types" + +const MANDATORY_PANE_FIELD_COUNT = 8 + +type ParsedPaneState = { + windowWidth: number + windowHeight: number + panes: TmuxPaneInfo[] +} + +type ParsedPaneLine = { + pane: TmuxPaneInfo + windowWidth: number + windowHeight: number +} + +type MandatoryPaneFields = [ + paneId: string, + widthString: string, + heightString: string, + leftString: string, + topString: string, + activeString: string, + windowWidthString: string, + windowHeightString: string, +] + +export function parsePaneStateOutput(stdout: string): ParsedPaneState | null { + const lines = stdout + .split("\n") + .map((line) => line.replace(/\r$/, "")) + .filter((line) => line.length > 0) + + if (lines.length === 0) return null + + const parsedPaneLines = lines + .map(parsePaneLine) + .filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null) + + if (parsedPaneLines.length === 0) return null + + const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1] + if (!latestPaneLine) return null + + return { + windowWidth: latestPaneLine.windowWidth, + windowHeight: latestPaneLine.windowHeight, + panes: parsedPaneLines.map(({ pane }) => pane), + } +} + +function parsePaneLine(line: string): ParsedPaneLine | null { + const fields = line.split("\t") + const mandatoryFields = getMandatoryPaneFields(fields) + if (!mandatoryFields) return null + + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields + + const width = parseInteger(widthString) + const height = parseInteger(heightString) + const left = parseInteger(leftString) + const top = parseInteger(topString) + const windowWidth = parseInteger(windowWidthString) + const windowHeight = parseInteger(windowHeightString) + + if ( + width === null || + height === null || + left === null || + top === null || + windowWidth === null || + windowHeight === null + ) { + return null + } + + return { + pane: { + paneId, + width, + height, + left, + top, + title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"), + isActive: activeString === "1", + }, + windowWidth, + windowHeight, + } +} + +function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null { + if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null + + const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields + + if ( + paneId === undefined || + widthString === undefined || + heightString === undefined || + leftString === undefined || + topString === undefined || + activeString === undefined || + windowWidthString === undefined || + windowHeightString === undefined + ) { + return null + } + + return [ + paneId, + widthString, + heightString, + leftString, + topString, + activeString, + windowWidthString, + windowHeightString, + ] +} + +function parseInteger(value: string): number | null { + const parsedValue = Number.parseInt(value, 10) + return Number.isNaN(parsedValue) ? null : parsedValue +} diff --git a/src/features/tmux-subagent/pane-state-querier.test.ts b/src/features/tmux-subagent/pane-state-querier.test.ts new file mode 100644 index 0000000000..d59d4cce49 --- /dev/null +++ b/src/features/tmux-subagent/pane-state-querier.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { parsePaneStateOutput } from "./pane-state-parser" + +describe("parsePaneStateOutput", () => { + test("accepts a single pane when tmux omits the empty trailing title field", () => { + // given + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n" + + // when + const result = parsePaneStateOutput(stdout) + + // then + expect(result).not.toBeNull() + expect(result).toEqual({ + windowWidth: 120, + windowHeight: 40, + panes: [ + { + paneId: "%0", + width: 120, + height: 40, + left: 0, + top: 0, + title: "", + isActive: true, + }, + ], + }) + }) + + test("handles CRLF line endings without dropping panes", () => { + // given + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n" + + // when + const result = parsePaneStateOutput(stdout) + + // then + expect(result).not.toBeNull() + expect(result?.panes).toEqual([ + { + paneId: "%0", + width: 120, + height: 40, + left: 0, + top: 0, + title: "", + isActive: true, + }, + { + paneId: "%1", + width: 60, + height: 40, + left: 60, + top: 0, + title: "agent", + isActive: false, + }, + ]) + }) + + test("preserves tabs inside pane titles", () => { + // given + const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n" + + // when + const result = parsePaneStateOutput(stdout) + + // then + expect(result).not.toBeNull() + expect(result?.panes[0]?.title).toBe("title\twith\ttabs") + }) +}) diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index 28d5158a44..005e316723 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -1,5 +1,6 @@ import { spawn } from "bun" import type { WindowState, TmuxPaneInfo } from "./types" +import { parsePaneStateOutput } from "./pane-state-parser" import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" import { log } from "../../shared" @@ -27,31 +28,12 @@ export async function queryWindowState(sourcePaneId: string): Promise a.left - b.left || a.top - b.top)