192 lines
5.2 KiB
TypeScript
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
|