@taranek/orche 0.0.32 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -48,16 +48,20 @@ function deliverReview(pendingPath) {
48
48
  }
49
49
  }
50
50
  async function runReview(worktreePath) {
51
- const resolvedPath = path.resolve(worktreePath);
51
+ const inputPath = path.resolve(worktreePath);
52
52
  // Refuse to run unless the target is inside a git worktree — otherwise the
53
53
  // review app may try to recursively watch enormous trees (e.g. $HOME).
54
+ // Anchor at the worktree root: `git diff --name-status` emits root-relative
55
+ // paths, so the review app must run from the top-level or file reads (which
56
+ // join cwd + path) break when invoked from a subdirectory.
57
+ let resolvedPath;
54
58
  try {
55
- execFileSync("git", ["-C", resolvedPath, "rev-parse", "--show-toplevel"], {
56
- stdio: ["ignore", "pipe", "ignore"],
57
- });
59
+ resolvedPath = execFileSync("git", ["-C", inputPath, "rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] })
60
+ .toString()
61
+ .trim();
58
62
  }
59
63
  catch {
60
- die(`not a git worktree: ${resolvedPath}`);
64
+ die(`not a git worktree: ${inputPath}`);
61
65
  }
62
66
  const args = ["--worktree=" + resolvedPath];
63
67
  console.log(`opening review for ${worktreePath}...`);
package/dist/layout.js CHANGED
@@ -19,7 +19,9 @@ export function buildLayout(mux, session, node, worktreePath) {
19
19
  for (let i = 1; i < node.panes.length; i++) {
20
20
  const child = node.panes[i];
21
21
  const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
22
- mux.splitPane(paneIds[0], node.direction, sizePercent, worktreePath);
22
+ // Chain off the previous sibling (like collectPaneMap) so flat columns keep
23
+ // left-to-right order once splitPane honors its target surface.
24
+ mux.splitPane(paneIds[i - 1], node.direction, sizePercent, worktreePath);
23
25
  paneIds.push(mux.getActivePaneId(session));
24
26
  }
25
27
  const allPaneIds = mux.listPaneIds(session);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { buildLayout } from "./layout.js";
6
+ /**
7
+ * A fake multiplexer that mimics cmux: each split creates a fresh surface that
8
+ * becomes the active pane (cmux auto-focuses the new split). It records every
9
+ * splitPane target so tests can assert which surface each split was anchored to.
10
+ */
11
+ class FakeMux {
12
+ name = "fake";
13
+ panes = ["p0"];
14
+ splitCalls = [];
15
+ commands = [];
16
+ createSession() { }
17
+ killSession() { }
18
+ attach() { }
19
+ getActivePaneId() {
20
+ return this.panes[this.panes.length - 1];
21
+ }
22
+ listPaneIds() {
23
+ return [...this.panes];
24
+ }
25
+ splitPane(target, direction) {
26
+ this.splitCalls.push({ target, direction });
27
+ this.panes.push(`p${this.panes.length}`);
28
+ }
29
+ focusPane() { }
30
+ renamePaneTitle() { }
31
+ sendCommand(target, command) {
32
+ this.commands.push({ target, command });
33
+ }
34
+ isInsideSession() {
35
+ return false;
36
+ }
37
+ detectFirstPane() {
38
+ return undefined;
39
+ }
40
+ }
41
+ describe("buildLayout", () => {
42
+ let tmp;
43
+ beforeEach(() => {
44
+ tmp = mkdtempSync(path.join(os.tmpdir(), "orche-layout-"));
45
+ });
46
+ afterEach(() => {
47
+ rmSync(tmp, { recursive: true, force: true });
48
+ });
49
+ it("chains flat sibling splits off the previous sibling (left-to-right order)", () => {
50
+ const mux = new FakeMux();
51
+ const layout = {
52
+ direction: "horizontal",
53
+ panes: [
54
+ { name: "a", command: "cmd-a" },
55
+ { name: "b", command: "cmd-b" },
56
+ { name: "c", command: "cmd-c" },
57
+ ],
58
+ };
59
+ buildLayout(mux, "sess", layout, tmp);
60
+ // Two splits to produce three columns, each anchored to the prior sibling
61
+ // (NOT all anchored to the first pane) so they render a|b|c in order.
62
+ expect(mux.splitCalls).toEqual([
63
+ { target: "p0", direction: "horizontal" },
64
+ { target: "p1", direction: "horizontal" },
65
+ ]);
66
+ });
67
+ it("anchors a nested split's children to that branch's surface", () => {
68
+ const mux = new FakeMux();
69
+ // vertical[ agent, horizontal[db, be, fe] ]
70
+ const layout = {
71
+ direction: "vertical",
72
+ panes: [
73
+ { name: "agent", command: "claude" },
74
+ {
75
+ direction: "horizontal",
76
+ panes: [
77
+ { name: "db", command: "db-up" },
78
+ { name: "be", command: "be-up" },
79
+ { name: "fe", command: "fe-up" },
80
+ ],
81
+ },
82
+ ],
83
+ };
84
+ buildLayout(mux, "sess", layout, tmp);
85
+ // 1 vertical split (agent | branch) + 2 horizontal splits for the columns.
86
+ expect(mux.splitCalls).toEqual([
87
+ { target: "p0", direction: "vertical" },
88
+ { target: "p1", direction: "horizontal" },
89
+ { target: "p2", direction: "horizontal" },
90
+ ]);
91
+ const session = JSON.parse(readFileSync(path.join(tmp, ".orche", "session.json"), "utf8"));
92
+ expect(session.panes).toEqual({
93
+ agent: "p0",
94
+ db: "p1",
95
+ be: "p2",
96
+ fe: "p3",
97
+ });
98
+ });
99
+ it("records a single pane with no splits", () => {
100
+ const mux = new FakeMux();
101
+ const layout = { name: "solo", command: "echo hi" };
102
+ buildLayout(mux, "sess", layout, tmp);
103
+ expect(mux.splitCalls).toEqual([]);
104
+ const session = JSON.parse(readFileSync(path.join(tmp, ".orche", "session.json"), "utf8"));
105
+ expect(session.panes).toEqual({ solo: "p0" });
106
+ });
107
+ });
@@ -7,7 +7,7 @@ export declare class CmuxMultiplexer implements Multiplexer {
7
7
  attach(_name: string): void;
8
8
  getActivePaneId(_session: string): string;
9
9
  listPaneIds(_session: string): string[];
10
- splitPane(_target: string, direction: "horizontal" | "vertical", _sizePercent: number, cwd: string): void;
10
+ splitPane(target: string, direction: "horizontal" | "vertical", _sizePercent: number, cwd: string): void;
11
11
  focusPane(_paneId: string): void;
12
12
  renamePaneTitle(paneId: string, name: string): void;
13
13
  sendCommand(target: string, command: string): void;
@@ -40,10 +40,14 @@ export class CmuxMultiplexer {
40
40
  log("listPaneIds →", this.surfaces);
41
41
  return [...this.surfaces];
42
42
  }
43
- splitPane(_target, direction, _sizePercent, cwd) {
43
+ splitPane(target, direction, _sizePercent, cwd) {
44
44
  const cmuxDir = direction === "horizontal" ? "right" : "down";
45
+ // Split the requested surface explicitly. Without --surface, cmux splits
46
+ // whatever is focused, which lands nested splits on the wrong pane
47
+ // (focusPane is a no-op and sendCommand refocuses the agent surface).
48
+ const targetFlag = target ? ` --surface ${target}` : "";
45
49
  // new-split returns "OK surface:<ref> workspace:<ref>"
46
- const output = run(`cmux new-split ${cmuxDir}`);
50
+ const output = run(`cmux new-split ${cmuxDir}${targetFlag}`);
47
51
  const match = output.match(/surface:(\S+)/);
48
52
  if (match) {
49
53
  const newRef = `surface:${match[1]}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taranek/orche",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "orche": "./dist/cli.js"
package/src/cli.ts CHANGED
@@ -48,16 +48,24 @@ function deliverReview(pendingPath: string): void {
48
48
  }
49
49
 
50
50
  async function runReview(worktreePath: string): Promise<void> {
51
- const resolvedPath = path.resolve(worktreePath);
51
+ const inputPath = path.resolve(worktreePath);
52
52
 
53
53
  // Refuse to run unless the target is inside a git worktree — otherwise the
54
54
  // review app may try to recursively watch enormous trees (e.g. $HOME).
55
+ // Anchor at the worktree root: `git diff --name-status` emits root-relative
56
+ // paths, so the review app must run from the top-level or file reads (which
57
+ // join cwd + path) break when invoked from a subdirectory.
58
+ let resolvedPath: string;
55
59
  try {
56
- execFileSync("git", ["-C", resolvedPath, "rev-parse", "--show-toplevel"], {
57
- stdio: ["ignore", "pipe", "ignore"],
58
- });
60
+ resolvedPath = execFileSync(
61
+ "git",
62
+ ["-C", inputPath, "rev-parse", "--show-toplevel"],
63
+ { stdio: ["ignore", "pipe", "ignore"] },
64
+ )
65
+ .toString()
66
+ .trim();
59
67
  } catch {
60
- die(`not a git worktree: ${resolvedPath}`);
68
+ die(`not a git worktree: ${inputPath}`);
61
69
  }
62
70
 
63
71
  const args = ["--worktree=" + resolvedPath];
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { buildLayout } from "./layout.js";
6
+ import type { Multiplexer } from "./multiplexer.js";
7
+ import type { PaneConfig, SplitConfig } from "./types.js";
8
+
9
+ interface SplitCall {
10
+ target: string;
11
+ direction: "horizontal" | "vertical";
12
+ }
13
+
14
+ /**
15
+ * A fake multiplexer that mimics cmux: each split creates a fresh surface that
16
+ * becomes the active pane (cmux auto-focuses the new split). It records every
17
+ * splitPane target so tests can assert which surface each split was anchored to.
18
+ */
19
+ class FakeMux implements Multiplexer {
20
+ readonly name = "fake";
21
+ panes: string[] = ["p0"];
22
+ splitCalls: SplitCall[] = [];
23
+ commands: { target: string; command: string }[] = [];
24
+
25
+ createSession(): void {}
26
+ killSession(): void {}
27
+ attach(): void {}
28
+
29
+ getActivePaneId(): string {
30
+ return this.panes[this.panes.length - 1];
31
+ }
32
+
33
+ listPaneIds(): string[] {
34
+ return [...this.panes];
35
+ }
36
+
37
+ splitPane(target: string, direction: "horizontal" | "vertical"): void {
38
+ this.splitCalls.push({ target, direction });
39
+ this.panes.push(`p${this.panes.length}`);
40
+ }
41
+
42
+ focusPane(): void {}
43
+ renamePaneTitle(): void {}
44
+ sendCommand(target: string, command: string): void {
45
+ this.commands.push({ target, command });
46
+ }
47
+
48
+ isInsideSession(): boolean {
49
+ return false;
50
+ }
51
+ detectFirstPane(): string | undefined {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ describe("buildLayout", () => {
57
+ let tmp: string;
58
+
59
+ beforeEach(() => {
60
+ tmp = mkdtempSync(path.join(os.tmpdir(), "orche-layout-"));
61
+ });
62
+
63
+ afterEach(() => {
64
+ rmSync(tmp, { recursive: true, force: true });
65
+ });
66
+
67
+ it("chains flat sibling splits off the previous sibling (left-to-right order)", () => {
68
+ const mux = new FakeMux();
69
+ const layout: SplitConfig = {
70
+ direction: "horizontal",
71
+ panes: [
72
+ { name: "a", command: "cmd-a" },
73
+ { name: "b", command: "cmd-b" },
74
+ { name: "c", command: "cmd-c" },
75
+ ],
76
+ };
77
+
78
+ buildLayout(mux, "sess", layout, tmp);
79
+
80
+ // Two splits to produce three columns, each anchored to the prior sibling
81
+ // (NOT all anchored to the first pane) so they render a|b|c in order.
82
+ expect(mux.splitCalls).toEqual([
83
+ { target: "p0", direction: "horizontal" },
84
+ { target: "p1", direction: "horizontal" },
85
+ ]);
86
+ });
87
+
88
+ it("anchors a nested split's children to that branch's surface", () => {
89
+ const mux = new FakeMux();
90
+ // vertical[ agent, horizontal[db, be, fe] ]
91
+ const layout: SplitConfig = {
92
+ direction: "vertical",
93
+ panes: [
94
+ { name: "agent", command: "claude" },
95
+ {
96
+ direction: "horizontal",
97
+ panes: [
98
+ { name: "db", command: "db-up" },
99
+ { name: "be", command: "be-up" },
100
+ { name: "fe", command: "fe-up" },
101
+ ],
102
+ } as SplitConfig,
103
+ ],
104
+ };
105
+
106
+ buildLayout(mux, "sess", layout, tmp);
107
+
108
+ // 1 vertical split (agent | branch) + 2 horizontal splits for the columns.
109
+ expect(mux.splitCalls).toEqual([
110
+ { target: "p0", direction: "vertical" },
111
+ { target: "p1", direction: "horizontal" },
112
+ { target: "p2", direction: "horizontal" },
113
+ ]);
114
+
115
+ const session = JSON.parse(
116
+ readFileSync(path.join(tmp, ".orche", "session.json"), "utf8")
117
+ );
118
+ expect(session.panes).toEqual({
119
+ agent: "p0",
120
+ db: "p1",
121
+ be: "p2",
122
+ fe: "p3",
123
+ });
124
+ });
125
+
126
+ it("records a single pane with no splits", () => {
127
+ const mux = new FakeMux();
128
+ const layout: PaneConfig = { name: "solo", command: "echo hi" };
129
+
130
+ buildLayout(mux, "sess", layout, tmp);
131
+
132
+ expect(mux.splitCalls).toEqual([]);
133
+ const session = JSON.parse(
134
+ readFileSync(path.join(tmp, ".orche", "session.json"), "utf8")
135
+ );
136
+ expect(session.panes).toEqual({ solo: "p0" });
137
+ });
138
+ });
package/src/layout.ts CHANGED
@@ -34,7 +34,9 @@ export function buildLayout(
34
34
  for (let i = 1; i < node.panes.length; i++) {
35
35
  const child = node.panes[i];
36
36
  const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
37
- mux.splitPane(paneIds[0], node.direction, sizePercent, worktreePath);
37
+ // Chain off the previous sibling (like collectPaneMap) so flat columns keep
38
+ // left-to-right order once splitPane honors its target surface.
39
+ mux.splitPane(paneIds[i - 1], node.direction, sizePercent, worktreePath);
38
40
  paneIds.push(mux.getActivePaneId(session));
39
41
  }
40
42
 
@@ -51,14 +51,18 @@ export class CmuxMultiplexer implements Multiplexer {
51
51
  }
52
52
 
53
53
  splitPane(
54
- _target: string,
54
+ target: string,
55
55
  direction: "horizontal" | "vertical",
56
56
  _sizePercent: number,
57
57
  cwd: string
58
58
  ): void {
59
59
  const cmuxDir = direction === "horizontal" ? "right" : "down";
60
+ // Split the requested surface explicitly. Without --surface, cmux splits
61
+ // whatever is focused, which lands nested splits on the wrong pane
62
+ // (focusPane is a no-op and sendCommand refocuses the agent surface).
63
+ const targetFlag = target ? ` --surface ${target}` : "";
60
64
  // new-split returns "OK surface:<ref> workspace:<ref>"
61
- const output = run(`cmux new-split ${cmuxDir}`);
65
+ const output = run(`cmux new-split ${cmuxDir}${targetFlag}`);
62
66
 
63
67
  const match = output.match(/surface:(\S+)/);
64
68
  if (match) {