Explorar o código

Merge pull request #534 from smatheusblu/feat/zellij-layout-directions

feat: map zellij main layouts to pane directions
Alvin hai 1 mes
pai
achega
397b13f8ff

+ 2 - 2
docs/configuration.md

@@ -108,8 +108,8 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
 | `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.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij maps `main-vertical` to right and `main-horizontal` to down |
+| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij |
 | `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 |

+ 15 - 3
docs/multiplexer-integration.md

@@ -128,7 +128,7 @@ Please analyze this codebase and create a documentation structure.
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
 | `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, or `"none"` |
-| `layout` | string | `"main-vertical"` | Layout preset for tmux only |
+| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij pane directions where possible |
 | `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 |
 
@@ -137,7 +137,7 @@ Please analyze this codebase and create a documentation structure.
 | 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 parent OpenCode 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"`; maps `main-*` layouts to pane directions |
 
 **Example: open Zellij subagents in the parent OpenCode tab**
 
@@ -175,7 +175,9 @@ This is converted automatically to `multiplexer.type: "tmux"`.
 
 ## Layouts
 
-These layouts apply to **tmux only**:
+Tmux supports full layout control and main pane sizing. Zellij maps only the
+`main-*` layout settings to pane creation directions; exact `main_pane_size`
+rebalancing is tmux-only.
 
 | Layout | Description |
 |--------|-------------|
@@ -185,6 +187,16 @@ These layouts apply to **tmux only**:
 | `even-horizontal` | All panes side by side |
 | `even-vertical` | All panes stacked vertically |
 
+For Zellij:
+
+| Layout | Zellij behavior |
+|--------|-----------------|
+| `main-vertical` | Opens new subagent panes to the right |
+| `main-horizontal` | Opens new subagent panes down |
+| `even-horizontal` | Uses Zellij's native pane placement |
+| `even-vertical` | Uses Zellij's native pane placement |
+| `tiled` | Uses Zellij's native pane placement |
+
 **Example: wide-screen layout**
 
 ```jsonc

+ 3 - 2
src/multiplexer/codemap.md

@@ -39,8 +39,9 @@
   - `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).
+  - Layout configuration maps `main-vertical` to right and `main-horizontal` to
+    down; `tiled`/`even-horizontal`/`even-vertical` use Zellij native placement
+    and `main_pane_size` remains a no-op.
 
 - `session-manager.ts` (`MultiplexerSessionManager`)
   - Initialized once from plugin context and config.

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

@@ -55,6 +55,51 @@ function commands(): string[][] {
   return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
 }
 
+async function spawnSecondAgentTabPane(
+  layout: 'main-vertical' | 'main-horizontal' | 'tiled',
+): Promise<string[] | undefined> {
+  const { ZellijMultiplexer } = await importFreshZellij();
+  const zellij = new ZellijMultiplexer(layout, 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 }));
+    }
+    if (command.includes('list-panes')) {
+      return createSpawnResult(0, 'PANE ID\nterminal_7\n');
+    }
+    if (command.includes('new-pane')) {
+      return createSpawnResult(0, 'terminal_8\n');
+    }
+    return createSpawnResult();
+  });
+
+  await zellij.spawnPane(
+    'session-1',
+    'First agent worker',
+    'http://localhost:4096',
+    '/repo',
+  );
+
+  await zellij.spawnPane(
+    'session-2',
+    'Second agent worker',
+    'http://localhost:4096',
+    '/repo',
+  );
+
+  return commands().findLast((command) => command.includes('new-pane'));
+}
+
 describe('ZellijMultiplexer', () => {
   const originalZellij = process.env.ZELLIJ;
   const originalZellijPaneId = process.env.ZELLIJ_PANE_ID;
@@ -107,6 +152,8 @@ describe('ZellijMultiplexer', () => {
       'new-pane',
       '--tab-id',
       '0',
+      '--direction',
+      'right',
       '--name',
       'Current tab worker',
       '--close-on-exit',
@@ -244,4 +291,144 @@ describe('ZellijMultiplexer', () => {
     expect(tabIdArgIndex).toBeGreaterThanOrEqual(0);
     expect(newPaneCommand?.[tabIdArgIndex + 1]).toBe('1');
   });
+
+  test('current-tab mode caches the fallback focused tab after parent tab lookup fails', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-vertical', 60, 'current-tab');
+    let currentTabId = 1;
+
+    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: currentTabId++ }));
+      }
+      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',
+    );
+    await zellij.spawnPane(
+      'session-2',
+      'Current tab worker 2',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommands = commands().filter((command) =>
+      command.includes('new-pane'),
+    );
+
+    expect(
+      newPaneCommands.map((command) => {
+        const tabIdArgIndex = command.indexOf('--tab-id');
+        return command[tabIdArgIndex + 1];
+      }),
+    ).toEqual(['1', '1']);
+  });
+
+  test('main-horizontal layout opens current-tab panes down', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('main-horizontal', 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 directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('even-horizontal layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('even-horizontal', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('even-vertical layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('even-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'),
+    );
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('tiled layout uses zellij native current-tab pane placement', async () => {
+    const { ZellijMultiplexer } = await importFreshZellij();
+    const zellij = new ZellijMultiplexer('tiled', 60, 'current-tab');
+
+    await zellij.spawnPane(
+      'session-1',
+      'Current tab worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const newPaneCommand = commands().find((command) =>
+      command.includes('new-pane'),
+    );
+
+    expect(newPaneCommand).not.toContain('--direction');
+  });
+
+  test('main-vertical layout opens agent-tab panes right', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('main-vertical');
+    const directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('right');
+  });
+
+  test('main-horizontal layout opens agent-tab panes down', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('main-horizontal');
+    const directionArgIndex = newPaneCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(newPaneCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('tiled layout uses zellij native agent-tab pane placement', async () => {
+    const newPaneCommand = await spawnSecondAgentTabPane('tiled');
+
+    expect(newPaneCommand).not.toContain('--direction');
+  });
 });

+ 33 - 7
src/multiplexer/zellij/index.ts

@@ -29,6 +29,8 @@ interface ZellijPaneInfo {
   tab_id?: number;
 }
 
+type ZellijPaneDirection = 'right' | 'down';
+
 export class ZellijMultiplexer implements Multiplexer {
   readonly type = 'zellij' as const;
 
@@ -39,17 +41,17 @@ export class ZellijMultiplexer implements Multiplexer {
   private firstPaneUsed = false;
   private parentTabId: string | null = null;
   private readonly parentPaneId = process.env.ZELLIJ_PANE_ID;
+  private readonly paneDirection: ZellijPaneDirection | null;
 
   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.
-    void layout;
+    // Note: Zellij does not support exact main pane sizing like tmux.
+    // Layout config is mapped to pane creation directions where possible.
     void mainPaneSize;
+    this.paneDirection = getPaneDirection(layout);
   }
 
   async isAvailable(): Promise<boolean> {
@@ -142,6 +144,7 @@ export class ZellijMultiplexer implements Multiplexer {
       'action',
       'new-pane',
       ...this.tabIdArgs(targetTabId),
+      ...this.directionArgs(),
       '--name',
       paneName,
       '--close-on-exit',
@@ -188,6 +191,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const args = [
         'action',
         'new-pane',
+        ...this.directionArgs(),
         '--name',
         paneName,
         '--close-on-exit',
@@ -230,6 +234,7 @@ export class ZellijMultiplexer implements Multiplexer {
     const args = [
       'action',
       'new-pane',
+      ...this.directionArgs(),
       '--name',
       paneName,
       '--close-on-exit',
@@ -518,8 +523,13 @@ export class ZellijMultiplexer implements Multiplexer {
     _layout: MultiplexerLayout,
     _mainPaneSize: number,
   ): Promise<void> {
-    // No-op for zellij - zellij uses its own native layout algorithm.
-    // Unlike tmux, zellij does not support programmatic layout control.
+    // No-op for zellij after panes are spawned. Zellij does not support tmux-like
+    // exact main pane sizing/rebalancing; layout is applied to future pane
+    // creation by mapping configured layouts to pane directions.
+  }
+
+  private directionArgs(): string[] {
+    return this.paneDirection ? ['--direction', this.paneDirection] : [];
   }
 
   private tabIdArgs(tabId: string | null): string[] {
@@ -537,7 +547,8 @@ export class ZellijMultiplexer implements Multiplexer {
       }
     }
 
-    return await this.getCurrentTabId(zellij);
+    this.parentTabId = await this.getCurrentTabId(zellij);
+    return this.parentTabId;
   }
 
   private async findTabIdForPane(
@@ -594,6 +605,21 @@ function normalizePaneId(paneId: string): string {
   return paneId.replace(/^terminal_/, '');
 }
 
+function getPaneDirection(
+  layout: MultiplexerLayout,
+): ZellijPaneDirection | null {
+  switch (layout) {
+    case 'main-vertical':
+      return 'right';
+    case 'main-horizontal':
+      return 'down';
+    case 'even-horizontal':
+    case 'even-vertical':
+    case 'tiled':
+      return null;
+  }
+}
+
 function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,