Просмотр исходного кода

feat(herdr): implement main-vertical agent-area stacking

Michael Henke 1 месяц назад
Родитель
Сommit
df5647ea48
2 измененных файлов с 115 добавлено и 38 удалено
  1. 33 0
      src/multiplexer/herdr/index.test.ts
  2. 82 38
      src/multiplexer/herdr/index.ts

+ 33 - 0
src/multiplexer/herdr/index.test.ts

@@ -441,6 +441,39 @@ describe('HerdrMultiplexer', () => {
     expect(herdr.agentAreaPaneId).toBeNull();
   });
 
+  test('main-vertical: 2nd spawn splits agent area down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'Agent 1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'Agent 2', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[0]).toEqual([
+      '/usr/bin/herdr', 'pane', 'split', 'w1:p1',
+      '--direction', 'right', '--cwd', '/repo', '--no-focus',
+    ]);
+    expect(splitCommands[1]).toEqual([
+      '/usr/bin/herdr', 'pane', 'split', 'w1:p2',
+      '--direction', 'down', '--cwd', '/repo', '--no-focus',
+    ]);
+  });
+
+  test('main-vertical: 3rd spawn splits same agent area down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane('s1', 'A1', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s2', 'A2', 'http://localhost:4096', '/repo');
+    await herdr.spawnPane('s3', 'A3', 'http://localhost:4096', '/repo');
+
+    const splitCommands = commands().filter((c) => c.includes('split'));
+    expect(splitCommands[2]).toEqual([
+      '/usr/bin/herdr', 'pane', 'split', 'w1:p2',
+      '--direction', 'down', '--cwd', '/repo', '--no-focus',
+    ]);
+  });
+
   test('applyLayout is a no-op', async () => {
     const { HerdrMultiplexer } = await importFreshHerdr();
     const herdr = new HerdrMultiplexer('main-vertical', 60);

+ 82 - 38
src/multiplexer/herdr/index.ts

@@ -37,6 +37,7 @@ export class HerdrMultiplexer implements Multiplexer {
   private readonly layout: MultiplexerLayout;
   private readonly paneDirection: HerdrPaneDirection;
   private agentAreaPaneId: string | null = null;
+  private lastSplitPaneId: string | null = null;
 
   constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
     // Herdr does not support exact main pane sizing like tmux.
@@ -73,44 +74,44 @@ export class HerdrMultiplexer implements Multiplexer {
     }
 
     try {
-      // 1. Split the parent pane to create a new one
-      const splitArgs = [
-        herdr,
-        'pane',
-        'split',
-        ...this.targetPaneArg(),
-        '--direction',
-        this.paneDirection,
-        '--cwd',
-        directory,
-        '--no-focus',
-      ];
-
-      log('[herdr] spawnPane: splitting pane', { args: splitArgs });
-
-      const splitProc = crossSpawn(splitArgs, {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
+      // Determine split target and direction based on layout + agent area state
+      let target: string[] = [];
+      let direction: HerdrPaneDirection = 'right';
+      let wasFirstChild = false;
+
+      if (this.layout === 'main-vertical' && this.agentAreaPaneId) {
+        target = [this.agentAreaPaneId];
+        direction = 'down';
+        const agentSplit = await this.runSplit(
+          target,
+          direction,
+          directory,
+        );
+        if (!agentSplit) {
+          log('[herdr] agent area split failed, falling back to parent', {
+            agentAreaPaneId: this.agentAreaPaneId,
+          });
+          this.agentAreaPaneId = null;
+        }
+      }
 
-      const splitExitCode = await splitProc.exited;
-      const splitStdout = await splitProc.stdout();
-      const splitStderr = await splitProc.stderr();
+      if (!this.agentAreaPaneId) {
+        // First child OR fallback after stale agent area
+        target = this.targetPaneArg();
+        direction = this.paneDirection;
+        wasFirstChild = true;
+      }
 
-      if (splitExitCode !== 0) {
-        log('[herdr] spawnPane: split failed', {
-          exitCode: splitExitCode,
-          stderr: splitStderr.trim(),
-        });
-        return { success: false };
+      let paneId: string | null = null;
+      if (wasFirstChild) {
+        paneId = await this.runSplit(target, direction, directory);
+      } else {
+        // agent area split already ran; use its result
+        paneId = this.lastSplitPaneId;
       }
 
-      // Parse JSON response to extract pane_id
-      const paneId = parsePaneId(splitStdout);
       if (!paneId) {
-        log('[herdr] spawnPane: could not parse pane_id from output', {
-          stdout: splitStdout.trim(),
-        });
+        log('[herdr] spawnPane: could not parse pane_id from output');
         return { success: false };
       }
 
@@ -127,11 +128,6 @@ export class HerdrMultiplexer implements Multiplexer {
         directory,
       );
 
-      log('[herdr] spawnPane: running attach command', {
-        paneId,
-        command: opencodeCmd,
-      });
-
       const runProc = crossSpawn([herdr, 'pane', 'run', paneId, opencodeCmd], {
         stdout: 'pipe',
         stderr: 'pipe',
@@ -147,6 +143,10 @@ export class HerdrMultiplexer implements Multiplexer {
         return { success: false };
       }
 
+      if (wasFirstChild && this.layout === 'main-vertical') {
+        this.agentAreaPaneId = paneId;
+      }
+
       log('[herdr] spawnPane: SUCCESS', { paneId });
       return { success: true, paneId };
     } catch (err) {
@@ -211,6 +211,50 @@ export class HerdrMultiplexer implements Multiplexer {
     // mapping configured layouts to pane split directions.
   }
 
+  private async runSplit(
+    target: string[],
+    direction: HerdrPaneDirection,
+    directory: string,
+  ): Promise<string | null> {
+    const herdr = await this.getBinary();
+    if (!herdr) return null;
+
+    const splitArgs = [
+      herdr,
+      'pane',
+      'split',
+      ...target,
+      '--direction',
+      direction,
+      '--cwd',
+      directory,
+      '--no-focus',
+    ];
+
+    log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+
+    const splitProc = crossSpawn(splitArgs, {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const splitExitCode = await splitProc.exited;
+    const splitStdout = await splitProc.stdout();
+    const splitStderr = await splitProc.stderr();
+
+    if (splitExitCode !== 0) {
+      log('[herdr] spawnPane: split failed', {
+        exitCode: splitExitCode,
+        stderr: splitStderr.trim(),
+      });
+      return null;
+    }
+
+    const paneId = parsePaneId(splitStdout);
+    this.lastSplitPaneId = paneId;
+    return paneId;
+  }
+
   private targetPaneArg(): string[] {
     return this.parentPaneId ? [this.parentPaneId] : ['--current'];
   }