Compare commits

...

2 Commits

Author SHA1 Message Date
YeonGyu-Kim
8b71559113 refactor(tmux): use dedicated pane state parser
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:09:23 +09:00
YeonGyu-Kim
26f7738397 fix(tmux): parse pane state output without trailing title
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:09:18 +09:00
3 changed files with 204 additions and 24 deletions

View File

@@ -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
}

View File

@@ -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")
})
})

View File

@@ -1,5 +1,6 @@
import { spawn } from "bun" import { spawn } from "bun"
import type { WindowState, TmuxPaneInfo } from "./types" import type { WindowState, TmuxPaneInfo } from "./types"
import { parsePaneStateOutput } from "./pane-state-parser"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { log } from "../../shared" import { log } from "../../shared"
@@ -27,31 +28,12 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
return null return null
} }
const lines = stdout.trim().split("\n").filter(Boolean) const parsedPaneState = parsePaneStateOutput(stdout)
if (lines.length === 0) return null if (!parsedPaneState) return null
let windowWidth = 0 const { panes } = parsedPaneState
let windowHeight = 0 const windowWidth = parsedPaneState.windowWidth
const panes: TmuxPaneInfo[] = [] const windowHeight = parsedPaneState.windowHeight
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 })
}
}
panes.sort((a, b) => a.left - b.left || a.top - b.top) panes.sort((a, b) => a.left - b.left || a.top - b.top)