Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/features/tmux-subagent/pane-state-parser.ts
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions src/features/tmux-subagent/pane-state-querier.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
30 changes: 6 additions & 24 deletions src/features/tmux-subagent/pane-state-querier.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -27,31 +28,12 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
return null
}

const lines = stdout.trim().split("\n").filter(Boolean)
if (lines.length === 0) return null
const parsedPaneState = parsePaneStateOutput(stdout)
if (!parsedPaneState) return null

let windowWidth = 0
let windowHeight = 0
const panes: TmuxPaneInfo[] = []

for (const line of lines) {
const fields = line.split("\t")
if (fields.length < 9) continue

const [paneId, widthStr, heightStr, leftStr, topStr, activeStr, windowWidthStr, windowHeightStr] = fields
const title = fields.slice(8).join("\t")
const width = parseInt(widthStr, 10)
const height = parseInt(heightStr, 10)
const left = parseInt(leftStr, 10)
const top = parseInt(topStr, 10)
const isActive = activeStr === "1"
windowWidth = parseInt(windowWidthStr, 10)
windowHeight = parseInt(windowHeightStr, 10)

if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
panes.push({ paneId, width, height, left, top, title, isActive })
}
}
const { panes } = parsedPaneState
const windowWidth = parsedPaneState.windowWidth
const windowHeight = parsedPaneState.windowHeight

panes.sort((a, b) => a.left - b.left || a.top - b.top)

Expand Down