Browse Source

Merge pull request #533 from smatheusblu/feat/zellij-current-tab-pane

Alvin 3 months ago
parent
commit
1f6624de84

+ 1 - 0
docs/configuration.md

@@ -110,6 +110,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
 | `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |
+| `multiplexer.zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `agent-tab` creates/reuses a dedicated `opencode-agents` tab; `current-tab` opens subagents as panes in the tab containing the parent OpenCode pane, falling back to the focused tab if the parent pane cannot be resolved |
 | `divoom.enabled` | boolean | `false` | Enable Divoom Bluetooth display status GIFs for plugin load and delegated agent calls |
 | `divoom.python` | string | Divoom MiniToo bundled Python | Python executable used to run Divoom MiniToo's `divoom_send.py` helper |
 | `divoom.script` | string | Divoom MiniToo `divoom_send.py` | Divoom sender script path |

+ 18 - 1
docs/multiplexer-integration.md

@@ -130,13 +130,30 @@ Please analyze this codebase and create a documentation structure.
 | `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, or `"none"` |
 | `layout` | string | `"main-vertical"` | Layout preset for tmux only |
 | `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`) |
+| `zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `"agent-tab"` creates/reuses a dedicated tab; `"current-tab"` opens panes in the tab containing the parent OpenCode pane |
 
 ### Supported Multiplexers
 
 | Multiplexer | Status | Notes |
 |-------------|--------|-------|
 | **Tmux** | ✅ Supported | Full layout control with `main-vertical`, `main-horizontal`, `tiled`, and more |
-| **Zellij** | ✅ Supported | Creates a dedicated `opencode-agents` tab and reuses the default pane |
+| **Zellij** | ✅ Supported | Creates a dedicated `opencode-agents` tab by default; can open panes in the parent OpenCode tab with `zellij_pane_mode: "current-tab"` |
+
+**Example: open Zellij subagents in the parent OpenCode tab**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "zellij",
+    "zellij_pane_mode": "current-tab"
+  }
+}
+```
+
+In `current-tab` mode, panes are targeted to the tab that contains the parent
+OpenCode pane, even if another Zellij tab is focused when a subagent starts.
+If the parent pane cannot be resolved, it falls back to the currently focused
+tab.
 
 ### Legacy tmux config
 

+ 8 - 0
oh-my-opencode-slim.schema.json

@@ -413,6 +413,14 @@
           "type": "number",
           "minimum": 20,
           "maximum": 80
+        },
+        "zellij_pane_mode": {
+          "default": "agent-tab",
+          "type": "string",
+          "enum": [
+            "agent-tab",
+            "current-tab"
+          ]
         }
       }
     },

+ 1 - 0
src/config/loader.ts

@@ -405,6 +405,7 @@ function migrateTmuxToMultiplexer(config: PluginConfig): PluginConfig {
         type: 'tmux',
         layout: config.tmux.layout ?? 'main-vertical',
         main_pane_size: config.tmux.main_pane_size ?? 60,
+        zellij_pane_mode: 'agent-tab',
       },
     };
   }

+ 5 - 0
src/config/schema.ts

@@ -124,6 +124,10 @@ export const MultiplexerLayoutSchema = z.enum([
 
 export type MultiplexerLayout = z.infer<typeof MultiplexerLayoutSchema>;
 
+// Zellij pane placement options
+export const ZellijPaneModeSchema = z.enum(['agent-tab', 'current-tab']);
+export type ZellijPaneMode = z.infer<typeof ZellijPaneModeSchema>;
+
 // Legacy Tmux layout options (for backward compatibility)
 export const TmuxLayoutSchema = MultiplexerLayoutSchema;
 export type TmuxLayout = MultiplexerLayout;
@@ -133,6 +137,7 @@ export const MultiplexerConfigSchema = z.object({
   type: MultiplexerTypeSchema.default('none'),
   layout: MultiplexerLayoutSchema.default('main-vertical'),
   main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
+  zellij_pane_mode: ZellijPaneModeSchema.default('agent-tab'),
 });
 
 export type MultiplexerConfig = z.infer<typeof MultiplexerConfigSchema>;

+ 1 - 0
src/index.ts

@@ -229,6 +229,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       type: config.multiplexer?.type ?? 'none',
       layout: config.multiplexer?.layout ?? 'main-vertical',
       main_pane_size: config.multiplexer?.main_pane_size ?? 60,
+      zellij_pane_mode: config.multiplexer?.zellij_pane_mode ?? 'agent-tab',
     };
 
     // Get multiplexer instance for capability checks

+ 3 - 0
src/multiplexer/codemap.md

@@ -36,6 +36,9 @@
   - First child uses default pane in that tab; additional children create panes.
   - Falls back to first available pane ID heuristics and restores original tab
     context around cross-tab operations.
+  - `current-tab` pane mode targets the tab containing the parent OpenCode pane
+    via `ZELLIJ_PANE_ID` + `list-panes --json --tab --all`, not whichever tab
+    is focused when a child session starts.
   - Layout configuration is accepted but effectively no-op (tool semantics differ
     from tmux).
 

+ 4 - 0
src/multiplexer/factory.test.ts

@@ -23,6 +23,7 @@ describe('multiplexer factory', () => {
       type: 'tmux',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     process.env.TMUX_PANE = '%2';
@@ -34,6 +35,7 @@ describe('multiplexer factory', () => {
       type: 'tmux',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     expect(first).not.toBeNull();
@@ -51,6 +53,7 @@ describe('multiplexer factory', () => {
       type: 'auto',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     process.env.TMUX_PANE = '%2';
@@ -62,6 +65,7 @@ describe('multiplexer factory', () => {
       type: 'auto',
       layout: 'main-vertical',
       main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
     });
 
     expect(first).not.toBeNull();

+ 6 - 1
src/multiplexer/factory.ts

@@ -32,7 +32,11 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       actualType = 'tmux';
       break;
     case 'zellij':
-      multiplexer = new ZellijMultiplexer(config.layout, config.main_pane_size);
+      multiplexer = new ZellijMultiplexer(
+        config.layout,
+        config.main_pane_size,
+        config.zellij_pane_mode,
+      );
       actualType = 'zellij';
       break;
     case 'auto': {
@@ -45,6 +49,7 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
         multiplexer = new ZellijMultiplexer(
           config.layout,
           config.main_pane_size,
+          config.zellij_pane_mode,
         );
         actualType = 'zellij';
       } else {

+ 1 - 0
src/multiplexer/session-manager.test.ts

@@ -64,6 +64,7 @@ const defaultMultiplexerConfig = {
   type: 'tmux' as const,
   layout: 'main-vertical' as const,
   main_pane_size: 60,
+  zellij_pane_mode: 'agent-tab' as const,
 };
 
 function createDeferred<T>() {

+ 247 - 0
src/multiplexer/zellij/index.test.ts

@@ -0,0 +1,247 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(
+  exitCode = 0,
+  stdout = '',
+  stderr = '',
+): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(stdout),
+    stderr: () => Promise.resolve(stderr),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+function createPaneListJson(parentTabId = 0): string {
+  return JSON.stringify([
+    {
+      id: 0,
+      is_plugin: false,
+      tab_id: parentTabId,
+    },
+    {
+      id: 4,
+      is_plugin: false,
+      tab_id: 1,
+    },
+  ]);
+}
+
+async function importFreshZellij() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+describe('ZellijMultiplexer', () => {
+  const originalZellij = process.env.ZELLIJ;
+  const originalZellijPaneId = process.env.ZELLIJ_PANE_ID;
+
+  beforeEach(() => {
+    process.env.ZELLIJ = '1';
+    process.env.ZELLIJ_PANE_ID = '0';
+
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson());
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+  });
+
+  afterEach(() => {
+    process.env.ZELLIJ = originalZellij;
+    process.env.ZELLIJ_PANE_ID = originalZellijPaneId;
+  });
+
+  test('current-tab mode spawns a pane in the parent OpenCode tab', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+
+    const allCommands = commands();
+    const newPaneCommand = allCommands.find((command) =>
+      command.includes('new-pane'),
+    );
+
+    expect(newPaneCommand).toEqual([
+      '/usr/bin/zellij',
+      'action',
+      'new-pane',
+      '--tab-id',
+      '0',
+      '--name',
+      'Current tab worker',
+      '--close-on-exit',
+      '--',
+      'sh',
+      '-lc',
+      "opencode attach 'http://localhost:4096' --session 'session-1' --dir '/repo'",
+    ]);
+    expect(allCommands.some((command) => command.includes('new-tab'))).toBe(
+      false,
+    );
+    expect(
+      allCommands.some((command) => command.includes('go-to-tab-by-id')),
+    ).toBe(false);
+  });
+
+  test('current-tab mode reports failure when zellij does not return a terminal pane id', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson());
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'plugin_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('current-tab mode targets the parent OpenCode tab even when another tab is focused', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(0, createPaneListJson(0));
+      }
+      if (command.includes('current-tab-info')) {
+        return createSpawnResult(0, JSON.stringify({ tab_id: 1 }));
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+
+    const tabIdArgIndex = newPaneCommand?.indexOf('--tab-id') ?? -1;
+    expect(result).toEqual({ success: true, paneId: 'terminal_2' });
+    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('0');
+  });
+
+  test('current-tab mode accepts terminal-prefixed parent pane ids', async () => {
+    process.env.ZELLIJ_PANE_ID = 'terminal_0';
+
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    const tabIdArgIndex = newPaneCommand?.indexOf('--tab-id') ?? -1;
+
+    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('0');
+  });
+
+  test('current-tab mode falls back to the focused tab if parent tab lookup fails', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-panes')) {
+        return createSpawnResult(1, '', 'list failed');
+      }
+      if (command.includes('current-tab-info')) {
+        return createSpawnResult(0, JSON.stringify({ tab_id: 1 }));
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_2\n');
+      }
+      return createSpawnResult();
+    });
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    const tabIdArgIndex = newPaneCommand?.indexOf('--tab-id') ?? -1;
+
+    expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('1');
+  });
+});

+ 125 - 4
src/multiplexer/zellij/index.ts

@@ -1,13 +1,18 @@
 /**
  * Zellij multiplexer implementation
  *
- * Creates a dedicated "opencode-agents" tab for all sub-agent panes.
+ * Creates panes for sub-agent sessions in Zellij.
+ *
+ * The default mode creates a dedicated "opencode-agents" tab:
  * - First sub-agent uses the default pane from new-tab
  * - Subsequent sub-agents create new panes
  * - User stays in their original tab
+ *
+ * The optional "current-tab" mode creates panes in the tab containing the
+ * parent OpenCode pane instead.
  */
 
-import type { MultiplexerLayout } from '../../config/schema';
+import type { MultiplexerLayout, ZellijPaneMode } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import type { Multiplexer, PaneResult } from '../types';
 
@@ -18,6 +23,12 @@ interface ZellijTabInfo {
   tab_id: number;
 }
 
+interface ZellijPaneInfo {
+  id: number;
+  is_plugin: boolean;
+  tab_id?: number;
+}
+
 export class ZellijMultiplexer implements Multiplexer {
   readonly type = 'zellij' as const;
 
@@ -26,8 +37,14 @@ export class ZellijMultiplexer implements Multiplexer {
   private agentTabId: string | null = null;
   private firstPaneId: string | null = null;
   private firstPaneUsed = false;
-
-  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+  private parentTabId: string | null = null;
+  private readonly parentPaneId = process.env.ZELLIJ_PANE_ID;
+
+  constructor(
+    layout: MultiplexerLayout = 'main-vertical',
+    mainPaneSize = 60,
+    private readonly paneMode: ZellijPaneMode = 'agent-tab',
+  ) {
     // Note: Zellij does NOT support layout configuration like tmux.
     // These params are accepted for API consistency but are no-ops.
     // Zellij uses its own native layout algorithm for pane arrangement.
@@ -58,6 +75,16 @@ export class ZellijMultiplexer implements Multiplexer {
     if (!zellij) return { success: false };
 
     try {
+      if (this.paneMode === 'current-tab') {
+        return await this.createPaneInCurrentTab(
+          zellij,
+          sessionId,
+          serverUrl,
+          directory,
+          description,
+        );
+      }
+
       // Ensure agent tab exists on first call
       if (!this.agentTabId) {
         const result = await this.ensureAgentTab(zellij);
@@ -96,6 +123,49 @@ export class ZellijMultiplexer implements Multiplexer {
     }
   }
 
+  private async createPaneInCurrentTab(
+    zellij: string,
+    sessionId: string,
+    serverUrl: string,
+    directory: string,
+    description: string,
+  ): Promise<PaneResult> {
+    const opencodeCmd = buildOpencodeAttachCommand(
+      sessionId,
+      serverUrl,
+      directory,
+    );
+    const paneName = description.slice(0, 30).replace(/"/g, '\\"');
+    const targetTabId = await this.getParentTabId(zellij);
+
+    const args = [
+      'action',
+      'new-pane',
+      ...this.tabIdArgs(targetTabId),
+      '--name',
+      paneName,
+      '--close-on-exit',
+      '--',
+      'sh',
+      '-lc',
+      opencodeCmd,
+    ];
+
+    const proc = crossSpawn([zellij, ...args], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const exitCode = await proc.exited;
+    const stdout = await proc.stdout();
+    const paneId = stdout.trim();
+
+    if (exitCode === 0 && paneId?.startsWith('terminal_')) {
+      return { success: true, paneId };
+    }
+    return { success: false };
+  }
+
   private async createPaneInAgentTab(
     zellij: string,
     sessionId: string,
@@ -452,6 +522,53 @@ export class ZellijMultiplexer implements Multiplexer {
     // Unlike tmux, zellij does not support programmatic layout control.
   }
 
+  private tabIdArgs(tabId: string | null): string[] {
+    return tabId ? ['--tab-id', tabId] : [];
+  }
+
+  private async getParentTabId(zellij: string): Promise<string | null> {
+    if (this.parentTabId) return this.parentTabId;
+
+    if (this.parentPaneId) {
+      const tabId = await this.findTabIdForPane(zellij, this.parentPaneId);
+      if (tabId) {
+        this.parentTabId = tabId;
+        return tabId;
+      }
+    }
+
+    return await this.getCurrentTabId(zellij);
+  }
+
+  private async findTabIdForPane(
+    zellij: string,
+    paneId: string,
+  ): Promise<string | null> {
+    try {
+      const proc = crossSpawn(
+        [zellij, 'action', 'list-panes', '--json', '--tab', '--all'],
+        {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        },
+      );
+
+      if ((await proc.exited) !== 0) return null;
+
+      const stdout = await proc.stdout();
+      const panes: ZellijPaneInfo[] = JSON.parse(stdout);
+      const normalizedPaneId = normalizePaneId(paneId);
+      const pane = panes.find(
+        (candidate) =>
+          !candidate.is_plugin && String(candidate.id) === normalizedPaneId,
+      );
+
+      return pane?.tab_id === undefined ? null : String(pane.tab_id);
+    } catch {
+      return null;
+    }
+  }
+
   private async getBinary(): Promise<string | null> {
     await this.isAvailable();
     return this.binaryPath;
@@ -473,6 +590,10 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 }
 
+function normalizePaneId(paneId: string): string {
+  return paneId.replace(/^terminal_/, '');
+}
+
 function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,