浏览代码

Merge pull request #774 from mhenke/fix/zellij-246

fix(zellij): scope list-panes to active tab so ensureAgentTab stops writing into main pane (#246)
Alvin 3 周之前
父节点
当前提交
b6c155bb7c
共有 3 个文件被更改,包括 150 次插入66 次删除
  1. 1 1
      src/multiplexer/zellij/codemap.md
  2. 115 3
      src/multiplexer/zellij/index.test.ts
  3. 34 62
      src/multiplexer/zellij/index.ts

+ 1 - 1
src/multiplexer/zellij/codemap.md

@@ -65,7 +65,7 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
    - Tries JSON output first (--json flag)
    - Falls back to text parsing if JSON unavailable
 2. getCurrentTabId() queries current-tab-info --json
-3. listPanes() parses list-panes output to track active panes
+3. getFirstPaneInTab() / findTabIdForPane() use listPanesJson() (list-panes --json --tab --all) filtered by tab_id
 4. findTabIdForPane() correlates pane IDs with tab IDs for parent tab tracking
 ```
 

+ 115 - 3
src/multiplexer/zellij/index.test.ts

@@ -74,8 +74,14 @@ async function spawnSecondAgentTabPane(
     if (command.includes('current-tab-info')) {
       return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
     }
-    if (command.includes('list-panes')) {
-      return createSpawnResult(0, 'PANE ID\nterminal_7\n');
+    if (command.includes('list-panes') && command.includes('--json')) {
+      return createSpawnResult(
+        0,
+        JSON.stringify([
+          { id: 0, is_plugin: false, tab_id: 0 },
+          { id: 7, is_plugin: false, tab_id: 5 },
+        ]),
+      );
     }
     if (command.includes('new-pane')) {
       return createSpawnResult(0, 'terminal_8\n');
@@ -206,7 +212,7 @@ describe('ZellijMultiplexer', () => {
         return createSpawnResult(0, '/usr/bin/zellij\n');
       }
       if (command.includes('list-panes')) {
-        return createSpawnResult(0, createPaneListJson(0));
+        return createSpawnResult(0, createPaneListJson());
       }
       if (command.includes('current-tab-info')) {
         return createSpawnResult(0, JSON.stringify({ tab_id: 1 }));
@@ -431,4 +437,110 @@ describe('ZellijMultiplexer', () => {
 
     expect(newPaneCommand).not.toContain('--direction');
   });
+
+  test('getFirstPaneInTab picks the target tab pane, not the current tab pane', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('current-tab-info')) {
+        return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
+      }
+      // getFirstPaneInTab: list-panes with --json --tab --all
+      if (command.includes('--json') && command.includes('--tab')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 7, is_plugin: false, tab_id: 5 },
+            { id: 8, is_plugin: false, tab_id: 5 },
+          ]),
+        );
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+    expect(result).toEqual({ success: true, paneId: 'terminal_7' });
+
+    const allCommands = commands();
+
+    // Should NOT create a new pane — reused the first pane via write-chars
+    const newPaneCmds = allCommands.filter((c) => c.includes('new-pane'));
+    expect(newPaneCmds).toHaveLength(0);
+
+    // Should focus the pane in tab 5 (terminal_7), not the one in tab 0 (terminal_0)
+    const focusPaneCmd = allCommands.find((c) => c.includes('focus-pane'));
+    expect(focusPaneCmd).toBeDefined();
+    expect(focusPaneCmd).toEqual(expect.arrayContaining(['terminal_7']));
+  });
+
+  test('getFirstPaneInTab null falls through to new-pane', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'agent-tab');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/zellij\n');
+      }
+      if (command.includes('list-tabs')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([{ name: 'opencode-agents', tab_id: 5 }]),
+        );
+      }
+      if (command.includes('current-tab-info')) {
+        return createSpawnResult(0, JSON.stringify({ tab_id: 0 }));
+      }
+      // getFirstPaneInTab: only main tab (tab 0) has panes, agent tab (5) has none
+      if (command.includes('--json') && command.includes('--tab')) {
+        return createSpawnResult(
+          0,
+          JSON.stringify([
+            { id: 0, is_plugin: false, tab_id: 0 },
+            { id: 4, is_plugin: false, tab_id: 1 },
+          ]),
+        );
+      }
+      if (command.includes('new-pane')) {
+        return createSpawnResult(0, 'terminal_8\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await zellij.spawnPane(
+      'session-1',
+      'First agent worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    // Falls through to createPaneInAgentTab -> new-pane
+    expect(result).toEqual({ success: true, paneId: 'terminal_8' });
+
+    const allCommands = commands();
+    const newPaneCmd = allCommands.find((c) => c.includes('new-pane'));
+    expect(newPaneCmd).toBeDefined();
+
+    // Should NOT use write-chars (no pane to reuse)
+    const writeCharsCmds = allCommands.filter((c) => c.includes('write-chars'));
+    expect(writeCharsCmds).toHaveLength(0);
+  });
 });

+ 34 - 62
src/multiplexer/zellij/index.ts

@@ -115,6 +115,8 @@ export class ZellijMultiplexer implements Multiplexer {
           this.firstPaneUsed = true;
           return { success: true, paneId: this.firstPaneId };
         }
+        // Reuse failed — don't keep retrying a known-bad pane
+        this.firstPaneUsed = true;
         // fall through to createPaneInAgentTab on failure
       }
 
@@ -323,7 +325,7 @@ export class ZellijMultiplexer implements Multiplexer {
 
   private async ensureAgentTab(
     zellij: string,
-  ): Promise<{ tabId: string; firstPaneId: string } | null> {
+  ): Promise<{ tabId: string; firstPaneId: string | null } | null> {
     try {
       // Try to find existing tab
       const existingTab = await this.findTabByName(zellij, 'opencode-agents');
@@ -334,13 +336,10 @@ export class ZellijMultiplexer implements Multiplexer {
         );
         return {
           tabId: existingTab.tabId,
-          firstPaneId: firstPane || 'terminal_0',
+          firstPaneId: firstPane,
         };
       }
 
-      // Get panes before creating tab
-      const beforePanes = await this.listPanes(zellij);
-
       // Create new tab
       const createProc = crossSpawn(
         [zellij, 'action', 'new-tab', '--name', 'opencode-agents'],
@@ -353,11 +352,25 @@ export class ZellijMultiplexer implements Multiplexer {
       const newTab = await this.findTabByName(zellij, 'opencode-agents');
       if (!newTab) return null;
 
-      // Get the new pane
-      const afterPanes = await this.listPanes(zellij);
-      const newPane = afterPanes.find((p) => !beforePanes.includes(p));
+      // Get the default pane in the new tab
+      const firstPane = await this.getFirstPaneInTab(zellij, newTab.tabId);
+      return { tabId: newTab.tabId, firstPaneId: firstPane };
+    } catch {
+      return null;
+    }
+  }
 
-      return { tabId: newTab.tabId, firstPaneId: newPane || 'terminal_0' };
+  private async listPanesJson(
+    zellij: string,
+  ): Promise<ZellijPaneInfo[] | 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();
+      return JSON.parse(stdout) as ZellijPaneInfo[];
     } catch {
       return null;
     }
@@ -367,26 +380,17 @@ export class ZellijMultiplexer implements Multiplexer {
     zellij: string,
     tabId: string,
   ): Promise<string | null> {
-    const originalTab = await this.getCurrentTabId(zellij);
-    await crossSpawn([zellij, 'action', 'go-to-tab-by-id', tabId], {
-      stdout: 'ignore',
-      stderr: 'ignore',
-    }).exited;
-
-    const panes = await this.listPanes(zellij);
-
-    // Restore original tab
-    if (originalTab) {
-      await crossSpawn(
-        [zellij, 'action', 'go-to-tab-by-id', String(originalTab)],
-        {
-          stdout: 'ignore',
-          stderr: 'ignore',
-        },
-      ).exited;
+    try {
+      const panes = await this.listPanesJson(zellij);
+      if (!panes) return null;
+      const pane = panes.find(
+        (candidate) =>
+          !candidate.is_plugin && candidate.tab_id === Number(tabId),
+      );
+      return pane ? `terminal_${pane.id}` : null;
+    } catch {
+      return null;
     }
-
-    return panes[0] || null;
   }
 
   private async findTabByName(
@@ -473,27 +477,6 @@ export class ZellijMultiplexer implements Multiplexer {
     }
   }
 
-  private async listPanes(zellij: string): Promise<string[]> {
-    try {
-      const proc = crossSpawn([zellij, 'action', 'list-panes'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) return [];
-
-      const stdout = await proc.stdout();
-      return stdout
-        .split('\n')
-        .slice(1)
-        .map((line) => line.trim().split(/\s+/)[0])
-        .filter((id) => id?.startsWith('terminal_'));
-    } catch {
-      return [];
-    }
-  }
-
   async closePane(paneId: string): Promise<boolean> {
     const zellij = await this.getBinary();
     return gracefulClosePane(zellij, paneId, {
@@ -541,24 +524,13 @@ export class ZellijMultiplexer implements Multiplexer {
     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 panes = await this.listPanesJson(zellij);
+      if (!panes) return null;
       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;