Files
dotfiles/.config/opencode/plugins/tmux-panes.ts
2026-03-11 12:49:11 +00:00

192 lines
5.2 KiB
TypeScript

import type { Plugin } from "@opencode-ai/plugin"
import { spawn } from "bun"
/**
* tmux-panes plugin
*
* When opencode spawns a background subagent, this plugin automatically opens
* a new tmux pane showing that subagent's live TUI via `opencode attach`.
*
* Layout:
* - First subagent: horizontal 60/40 split — main pane on left, subagent on right
* - Additional subagents: stacked vertically in the right column
* - Panes close automatically when subagent sessions end
*
* Only activates when running inside a tmux session (TMUX env var is set).
*/
const isInsideTmux = () => Boolean(process.env.TMUX)
const getCurrentPaneId = () => process.env.TMUX_PANE
const runTmux = async (args: string[]) => {
const proc = spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
return {
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}
}
const removeItem = (items: string[], value: string) => {
const idx = items.indexOf(value)
if (idx !== -1) items.splice(idx, 1)
}
const plugin: Plugin = async (ctx) => {
if (!isInsideTmux()) return {}
const sessions = new Map<string, string>() // sessionId → tmux paneId
const sourcePaneId = getCurrentPaneId()
const serverUrl = (ctx.serverUrl?.toString() ?? "").replace(/\/$/, "")
// Ordered list of pane IDs in the right column.
// Empty = no right column yet; length > 0 = right column exists.
const rightColumnPanes: string[] = []
let paneOps = Promise.resolve()
const getWindowInfo = async () => {
const targetPane = sourcePaneId ?? rightColumnPanes[0]
if (!targetPane) return null
const result = await runTmux([
"display-message",
"-p",
"-t",
targetPane,
"#{window_id} #{window_width}",
])
if (result.exitCode !== 0 || !result.stdout) return null
const [windowId, widthText] = result.stdout.split(/\s+/, 2)
const width = Number(widthText)
if (!windowId || Number.isNaN(width)) return null
return { windowId, width }
}
const applyLayout = async () => {
if (rightColumnPanes.length === 0) return
const windowInfo = await getWindowInfo()
if (!windowInfo) return
const mainWidth = Math.max(1, Math.round(windowInfo.width * 0.6))
await runTmux([
"set-window-option",
"-t",
windowInfo.windowId,
"main-pane-width",
String(mainWidth),
])
await runTmux(["select-layout", "-t", sourcePaneId ?? rightColumnPanes[0], "main-vertical"])
}
const closeSessionPane = async (sessionId: string) => {
const paneId = sessions.get(sessionId)
if (!paneId) return
await runTmux(["kill-pane", "-t", paneId])
sessions.delete(sessionId)
removeItem(rightColumnPanes, paneId)
await applyLayout()
}
const enqueuePaneOp = (operation: () => Promise<void>) => {
paneOps = paneOps.then(operation).catch(() => {})
return paneOps
}
const isTerminalSessionUpdate = (info: any) =>
Boolean(info?.time?.archived || info?.time?.compacting)
return {
event: async ({ event }) => {
// Spawn a new pane when a subagent session is created
if (event.type === "session.created") {
const info = (event as any).properties?.info
// parentID presence distinguishes subagents from the root session
if (!info?.id || !info?.parentID) return
const sessionId: string = info.id
if (sessions.has(sessionId)) return
await enqueuePaneOp(async () => {
if (sessions.has(sessionId)) return
const cmd = `opencode attach ${serverUrl} --session ${sessionId}`
let args: string[]
if (rightColumnPanes.length === 0) {
const windowInfo = await getWindowInfo()
const rightWidth = windowInfo
? Math.max(1, Math.round(windowInfo.width * 0.4))
: 40
args = [
"split-window",
"-h",
"-l",
String(rightWidth),
"-d",
"-P",
"-F",
"#{pane_id}",
...(sourcePaneId ? ["-t", sourcePaneId] : []),
cmd,
]
} else {
const lastRightPane = rightColumnPanes[rightColumnPanes.length - 1]
args = [
"split-window",
"-v",
"-d",
"-P",
"-F",
"#{pane_id}",
"-t",
lastRightPane,
cmd,
]
}
const result = await runTmux(args)
const paneId = result.stdout
if (result.exitCode === 0 && paneId) {
sessions.set(sessionId, paneId)
rightColumnPanes.push(paneId)
await applyLayout()
}
})
}
// Kill the pane when the subagent session ends
if (event.type === "session.deleted") {
const info = (event as any).properties?.info
if (!info?.id) return
await enqueuePaneOp(() => closeSessionPane(info.id))
}
if (event.type === "session.updated") {
const info = (event as any).properties?.info
if (!info?.id || !sessions.has(info.id) || !isTerminalSessionUpdate(info)) return
await enqueuePaneOp(() => closeSessionPane(info.id))
}
if (event.type === "session.status") {
const sessionID = (event as any).properties?.sessionID
const statusType = (event as any).properties?.status?.type
if (!sessionID || !sessions.has(sessionID) || statusType !== "idle") return
await enqueuePaneOp(() => closeSessionPane(sessionID))
}
},
}
}
export default plugin