Browse Source

fix: target parent zellij tab for current-tab mode

Matheus Nogueira 1 month ago
parent
commit
484a410c13

+ 1 - 1
docs/configuration.md

@@ -110,7 +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 current tab |
+| `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 |

+ 8 - 3
docs/multiplexer-integration.md

@@ -130,16 +130,16 @@ 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 active tab |
+| `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 by default; can open panes in the current tab with `zellij_pane_mode: "current-tab"` |
+| **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 current tab**
+**Example: open Zellij subagents in the parent OpenCode tab**
 
 ```jsonc
 {
@@ -150,6 +150,11 @@ Please analyze this codebase and create a documentation structure.
 }
 ```
 
+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
 
 Older configs still work:

+ 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).
 

+ 122 - 1
src/multiplexer/zellij/index.test.ts

@@ -32,6 +32,21 @@ function createSpawnResult(
   };
 }
 
+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++}`);
 }
@@ -42,15 +57,20 @@ function commands(): 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');
       }
@@ -60,9 +80,10 @@ describe('ZellijMultiplexer', () => {
 
   afterEach(() => {
     process.env.ZELLIJ = originalZellij;
+    process.env.ZELLIJ_PANE_ID = originalZellijPaneId;
   });
 
-  test('current-tab mode spawns a pane in the active tab', async () => {
+  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');
 
@@ -84,6 +105,8 @@ describe('ZellijMultiplexer', () => {
       '/usr/bin/zellij',
       'action',
       'new-pane',
+      '--tab-id',
+      '0',
       '--name',
       'Current tab worker',
       '--close-on-exit',
@@ -108,6 +131,9 @@ describe('ZellijMultiplexer', () => {
       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');
       }
@@ -123,4 +149,99 @@ describe('ZellijMultiplexer', () => {
 
     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');
+  });
 });

+ 63 - 1
src/multiplexer/zellij/index.ts

@@ -8,7 +8,8 @@
  * - Subsequent sub-agents create new panes
  * - User stays in their original tab
  *
- * The optional "current-tab" mode creates panes in the active tab instead.
+ * The optional "current-tab" mode creates panes in the tab containing the
+ * parent OpenCode pane instead.
  */
 
 import type { MultiplexerLayout, ZellijPaneMode } from '../../config/schema';
@@ -22,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;
 
@@ -30,6 +37,8 @@ export class ZellijMultiplexer implements Multiplexer {
   private agentTabId: string | null = null;
   private firstPaneId: string | null = null;
   private firstPaneUsed = false;
+  private parentTabId: string | null = null;
+  private readonly parentPaneId = process.env.ZELLIJ_PANE_ID;
 
   constructor(
     layout: MultiplexerLayout = 'main-vertical',
@@ -127,10 +136,12 @@ export class ZellijMultiplexer implements Multiplexer {
       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',
@@ -511,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;
@@ -532,6 +590,10 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 }
 
+function normalizePaneId(paneId: string): string {
+  return paneId.replace(/^terminal_/, '');
+}
+
 function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,