|
|
@@ -10,7 +10,8 @@ import {
|
|
|
findBinary,
|
|
|
gracefulClosePane,
|
|
|
} from '../shared';
|
|
|
-import type { Multiplexer, PaneResult } from '../types';
|
|
|
+import { readTmuxPane } from '../tmux-pane-registry';
|
|
|
+import type { Multiplexer, PaneResult, PaneSpawnOptions } from '../types';
|
|
|
|
|
|
const TMUX_LAYOUT_DEBOUNCE_MS = 150;
|
|
|
|
|
|
@@ -22,8 +23,8 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
private storedLayout: MultiplexerLayout;
|
|
|
private storedMainPaneSize: number;
|
|
|
private targetPane = process.env.TMUX_PANE;
|
|
|
- private layoutTimer?: ReturnType<typeof setTimeout>;
|
|
|
- private layoutGeneration = 0;
|
|
|
+ private layoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
+ private paneTargets = new Map<string, string | undefined>();
|
|
|
|
|
|
constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
|
|
|
this.storedLayout = layout;
|
|
|
@@ -49,6 +50,7 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
description: string,
|
|
|
serverUrl: string,
|
|
|
directory: string,
|
|
|
+ options?: PaneSpawnOptions,
|
|
|
): Promise<PaneResult> {
|
|
|
const tmux = await this.getBinary();
|
|
|
if (!tmux) {
|
|
|
@@ -64,37 +66,35 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
directory,
|
|
|
);
|
|
|
|
|
|
- // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
|
|
|
- const args = [
|
|
|
- 'split-window',
|
|
|
- '-h', // Horizontal split (pane to the right)
|
|
|
- '-d', // Don't switch focus
|
|
|
- '-P', // Print pane info
|
|
|
- '-F',
|
|
|
- '#{pane_id}', // Format: just the pane ID
|
|
|
- ...this.targetArgs(),
|
|
|
- opencodeCmd,
|
|
|
- ];
|
|
|
-
|
|
|
- log('[tmux] spawnPane: executing', { tmux, args });
|
|
|
-
|
|
|
- const proc = crossSpawn([tmux, ...args], {
|
|
|
- stdout: 'pipe',
|
|
|
- stderr: 'pipe',
|
|
|
- });
|
|
|
+ const registeredTarget = options?.parentSessionId
|
|
|
+ ? readTmuxPane(options.parentSessionId)
|
|
|
+ : undefined;
|
|
|
+ let targetPane = registeredTarget ?? this.targetPane;
|
|
|
+ let result = await this.splitPane(tmux, targetPane, opencodeCmd);
|
|
|
+
|
|
|
+ if (
|
|
|
+ result.exitCode !== 0 &&
|
|
|
+ registeredTarget &&
|
|
|
+ this.targetPane !== registeredTarget
|
|
|
+ ) {
|
|
|
+ log('[tmux] spawnPane: registered target failed, using fallback', {
|
|
|
+ registeredTarget,
|
|
|
+ fallbackTarget: this.targetPane,
|
|
|
+ });
|
|
|
+ targetPane = this.targetPane;
|
|
|
+ result = await this.splitPane(tmux, targetPane, opencodeCmd);
|
|
|
+ }
|
|
|
|
|
|
- const exitCode = await proc.exited;
|
|
|
- const stdout = await proc.stdout();
|
|
|
- const stderr = await proc.stderr();
|
|
|
- const paneId = stdout.trim();
|
|
|
+ const paneId = result.stdout.trim();
|
|
|
|
|
|
log('[tmux] spawnPane: result', {
|
|
|
- exitCode,
|
|
|
+ exitCode: result.exitCode,
|
|
|
paneId,
|
|
|
- stderr: stderr.trim(),
|
|
|
+ stderr: result.stderr.trim(),
|
|
|
+ targetPane,
|
|
|
});
|
|
|
|
|
|
- if (exitCode === 0 && paneId) {
|
|
|
+ if (result.exitCode === 0 && paneId) {
|
|
|
// Rename the pane for visibility
|
|
|
const renameProc = crossSpawn(
|
|
|
[tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
|
|
|
@@ -103,7 +103,8 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
await renameProc.exited;
|
|
|
|
|
|
// Rebalance panes after bursts of child sessions settle.
|
|
|
- this.scheduleLayout();
|
|
|
+ this.paneTargets.set(paneId, targetPane);
|
|
|
+ this.scheduleLayout(targetPane);
|
|
|
|
|
|
log('[tmux] spawnPane: SUCCESS', { paneId });
|
|
|
return { success: true, paneId };
|
|
|
@@ -118,11 +119,15 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
|
|
|
async closePane(paneId: string): Promise<boolean> {
|
|
|
const tmux = await this.getBinary();
|
|
|
+ const layoutTarget = this.paneTargets.get(paneId) ?? this.targetPane;
|
|
|
const closed = await gracefulClosePane(tmux, paneId, {
|
|
|
ctrlC: ['send-keys', '-t', paneId, 'C-c'],
|
|
|
close: ['kill-pane', '-t', paneId],
|
|
|
});
|
|
|
- if (closed) this.scheduleLayout();
|
|
|
+ if (closed) {
|
|
|
+ this.paneTargets.delete(paneId);
|
|
|
+ this.scheduleLayout(layoutTarget);
|
|
|
+ }
|
|
|
return closed;
|
|
|
}
|
|
|
|
|
|
@@ -130,31 +135,32 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
layout: MultiplexerLayout,
|
|
|
mainPaneSize: number,
|
|
|
): Promise<void> {
|
|
|
- if (this.layoutTimer) {
|
|
|
- clearTimeout(this.layoutTimer);
|
|
|
- this.layoutTimer = undefined;
|
|
|
- }
|
|
|
-
|
|
|
- this.layoutGeneration++;
|
|
|
- await this.applyLayoutNow(layout, mainPaneSize);
|
|
|
+ for (const timer of this.layoutTimers.values()) clearTimeout(timer);
|
|
|
+ this.layoutTimers.clear();
|
|
|
+ await this.applyLayoutNow(layout, mainPaneSize, this.targetPane);
|
|
|
}
|
|
|
|
|
|
- private scheduleLayout(): void {
|
|
|
- if (this.layoutTimer) clearTimeout(this.layoutTimer);
|
|
|
-
|
|
|
- const gen = ++this.layoutGeneration;
|
|
|
- this.layoutTimer = setTimeout(() => {
|
|
|
- this.layoutTimer = undefined;
|
|
|
- if (this.layoutGeneration === gen) {
|
|
|
- void this.applyLayoutNow(this.storedLayout, this.storedMainPaneSize);
|
|
|
- }
|
|
|
+ private scheduleLayout(targetPane: string | undefined): void {
|
|
|
+ const key = targetPane ?? '';
|
|
|
+ const pending = this.layoutTimers.get(key);
|
|
|
+ if (pending) clearTimeout(pending);
|
|
|
+
|
|
|
+ const timer = setTimeout(() => {
|
|
|
+ this.layoutTimers.delete(key);
|
|
|
+ void this.applyLayoutNow(
|
|
|
+ this.storedLayout,
|
|
|
+ this.storedMainPaneSize,
|
|
|
+ targetPane,
|
|
|
+ );
|
|
|
}, TMUX_LAYOUT_DEBOUNCE_MS);
|
|
|
- this.layoutTimer.unref?.();
|
|
|
+ this.layoutTimers.set(key, timer);
|
|
|
+ timer.unref?.();
|
|
|
}
|
|
|
|
|
|
private async applyLayoutNow(
|
|
|
layout: MultiplexerLayout,
|
|
|
mainPaneSize: number,
|
|
|
+ targetPane: string | undefined,
|
|
|
): Promise<void> {
|
|
|
const tmux = await this.getBinary();
|
|
|
if (!tmux) return;
|
|
|
@@ -167,7 +173,7 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
// Apply the layout
|
|
|
const layoutResult = await this.runTmux(tmux, [
|
|
|
'select-layout',
|
|
|
- ...this.targetArgs(),
|
|
|
+ ...this.targetArgs(targetPane),
|
|
|
layout,
|
|
|
]);
|
|
|
if (layoutResult !== 0) return;
|
|
|
@@ -179,7 +185,7 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
|
|
|
const sizeResult = await this.runTmux(tmux, [
|
|
|
'set-window-option',
|
|
|
- ...this.targetArgs(),
|
|
|
+ ...this.targetArgs(targetPane),
|
|
|
sizeOption,
|
|
|
`${mainPaneSize}%`,
|
|
|
]);
|
|
|
@@ -188,7 +194,7 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
// Reapply layout to use the new size
|
|
|
const reapplyResult = await this.runTmux(tmux, [
|
|
|
'select-layout',
|
|
|
- ...this.targetArgs(),
|
|
|
+ ...this.targetArgs(targetPane),
|
|
|
layout,
|
|
|
]);
|
|
|
if (reapplyResult !== 0) return;
|
|
|
@@ -228,7 +234,36 @@ export class TmuxMultiplexer implements Multiplexer {
|
|
|
return this.binaryPath;
|
|
|
}
|
|
|
|
|
|
- private targetArgs(): string[] {
|
|
|
- return this.targetPane ? ['-t', this.targetPane] : [];
|
|
|
+ private async splitPane(
|
|
|
+ tmux: string,
|
|
|
+ targetPane: string | undefined,
|
|
|
+ opencodeCmd: string,
|
|
|
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
|
+ const args = [
|
|
|
+ 'split-window',
|
|
|
+ '-h',
|
|
|
+ '-d',
|
|
|
+ '-P',
|
|
|
+ '-F',
|
|
|
+ '#{pane_id}',
|
|
|
+ ...this.targetArgs(targetPane),
|
|
|
+ opencodeCmd,
|
|
|
+ ];
|
|
|
+ log('[tmux] spawnPane: executing', { tmux, args });
|
|
|
+
|
|
|
+ const proc = crossSpawn([tmux, ...args], {
|
|
|
+ stdout: 'pipe',
|
|
|
+ stderr: 'pipe',
|
|
|
+ });
|
|
|
+ const [exitCode, stdout, stderr] = await Promise.all([
|
|
|
+ proc.exited,
|
|
|
+ proc.stdout(),
|
|
|
+ proc.stderr(),
|
|
|
+ ]);
|
|
|
+ return { exitCode, stdout, stderr };
|
|
|
+ }
|
|
|
+
|
|
|
+ private targetArgs(targetPane = this.targetPane): string[] {
|
|
|
+ return targetPane ? ['-t', targetPane] : [];
|
|
|
}
|
|
|
}
|