@bastani/atomic 0.5.0-1

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.
Files changed (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,661 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildConnector, buildMergeConnector } from "./connectors.ts";
3
+ import type { ConnectorResult } from "./connectors.ts";
4
+ import type { LayoutNode } from "./layout.ts";
5
+ import { NODE_W, NODE_H } from "./layout.ts";
6
+ import type { GraphTheme } from "./graph-theme.ts";
7
+ import type { SessionStatus } from "./orchestrator-panel-types.ts";
8
+
9
+ // ─── Mock Theme ──────────────────────────────────────────────────────────────
10
+
11
+ const mockTheme: GraphTheme = {
12
+ background: "#000000",
13
+ backgroundElement: "#111111",
14
+ text: "#ffffff",
15
+ textMuted: "#aaaaaa",
16
+ textDim: "#666666",
17
+ primary: "#0088ff",
18
+ success: "#00cc44",
19
+ error: "#ff2244",
20
+ warning: "#ffaa00",
21
+ info: "#00aaff",
22
+ border: "#334455",
23
+ borderActive: "#aabbcc",
24
+ };
25
+
26
+ // ─── Helper: make LayoutNode ─────────────────────────────────────────────────
27
+
28
+ function makeNode(
29
+ overrides: Partial<LayoutNode> & { name: string },
30
+ ): LayoutNode {
31
+ return {
32
+ status: "pending" as SessionStatus,
33
+ parents: [],
34
+ children: [],
35
+ depth: 0,
36
+ x: 0,
37
+ y: 0,
38
+ startedAt: null,
39
+ endedAt: null,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ // Helper: center x of a node
45
+ function cx(node: LayoutNode): number {
46
+ return node.x + Math.floor(NODE_W / 2);
47
+ }
48
+
49
+ // ─── buildConnector ──────────────────────────────────────────────────────────
50
+
51
+ describe("buildConnector", () => {
52
+ test("returns null when parent has no children", () => {
53
+ const parent = makeNode({ name: "root" });
54
+ const result = buildConnector(parent, { 0: NODE_H }, mockTheme);
55
+ expect(result).toBeNull();
56
+ });
57
+
58
+ test("single child directly below (aligned centers) produces straight vertical connector", () => {
59
+ // parent center x = 0 + 18 = 18
60
+ // child center x = 0 + 18 = 18 (same)
61
+ const child = makeNode({ name: "child", x: 0, y: 7, depth: 1 });
62
+ const parent = makeNode({
63
+ name: "parent",
64
+ x: 0,
65
+ y: 0,
66
+ depth: 0,
67
+ children: [child],
68
+ });
69
+ const rowH = { 0: NODE_H };
70
+ // parentBottom = 0 + 4 = 4; firstChildRow = 7; numRows = 3
71
+ const result = buildConnector(parent, rowH, mockTheme);
72
+
73
+ expect(result).not.toBeNull();
74
+ const r = result as ConnectorResult;
75
+ expect(r.col).toBe(cx(parent)); // 18
76
+ expect(r.row).toBe(4); // parentBottom
77
+ expect(r.width).toBe(1);
78
+ expect(r.height).toBe(3); // numRows
79
+ expect(r.text).toBe("│\n│\n│");
80
+ expect(r.color).toBe(mockTheme.borderActive);
81
+ });
82
+
83
+ test("single child with offset center produces horizontal bar with junctions", () => {
84
+ // parent: x=0 → pcx=18
85
+ // child: x=42 → cx=60 (not aligned)
86
+ const child = makeNode({ name: "child", x: 42, y: 7, depth: 1 });
87
+ const parent = makeNode({
88
+ name: "parent",
89
+ x: 0,
90
+ y: 0,
91
+ depth: 0,
92
+ children: [child],
93
+ });
94
+ const rowH = { 0: NODE_H };
95
+ // parentBottom=4, numRows=3, barRow=2
96
+ // allCols=[18,60], minCol=18, maxCol=60, width=43
97
+ // parent at minCol, not childAtParent → '╰'
98
+ // child at maxCol → '╮'
99
+ const result = buildConnector(parent, rowH, mockTheme);
100
+
101
+ expect(result).not.toBeNull();
102
+ const r = result as ConnectorResult;
103
+ expect(r.col).toBe(18);
104
+ expect(r.row).toBe(4);
105
+ expect(r.width).toBe(43);
106
+ expect(r.height).toBe(3);
107
+ expect(r.color).toBe(mockTheme.borderActive);
108
+
109
+ const lines = r.text.split("\n");
110
+ expect(lines.length).toBe(3);
111
+ // stem rows: '│' at col 0, rest spaces
112
+ expect(lines[0]![0]).toBe("│");
113
+ expect(lines[1]![0]).toBe("│");
114
+ // bar row: '╰' at left, '╮' at right
115
+ expect(lines[2]![0]).toBe("╰");
116
+ expect(lines[2]![42]).toBe("╮");
117
+ // bar row filled with '─' between junctions
118
+ expect(lines[2]![1]).toBe("─");
119
+ expect(lines[2]![41]).toBe("─");
120
+ });
121
+
122
+ test("two children produces horizontal bar connecting both with correct width", () => {
123
+ // parent: x=0 → pcx=18
124
+ // child1: x=0 → cx=18 (same as parent)
125
+ // child2: x=42 → cx=60
126
+ const child1 = makeNode({ name: "c1", x: 0, y: 7, depth: 1 });
127
+ const child2 = makeNode({ name: "c2", x: 42, y: 7, depth: 1 });
128
+ const parent = makeNode({
129
+ name: "parent",
130
+ x: 0,
131
+ y: 0,
132
+ depth: 0,
133
+ children: [child1, child2],
134
+ });
135
+ const rowH = { 0: NODE_H };
136
+ // allCols=[18,18,60], minCol=18, maxCol=60, width=43
137
+ // childAtParent=true (child1.cx=18=pcx)
138
+ // parent at minCol + childAtParent → '├'
139
+ // child2 at maxCol → '╮'
140
+ const result = buildConnector(parent, rowH, mockTheme);
141
+
142
+ expect(result).not.toBeNull();
143
+ const r = result as ConnectorResult;
144
+ expect(r.col).toBe(18);
145
+ expect(r.row).toBe(4);
146
+ expect(r.width).toBe(43);
147
+ expect(r.height).toBe(3);
148
+
149
+ const lines = r.text.split("\n");
150
+ const barLine = lines[2]!;
151
+ expect(barLine[0]).toBe("├");
152
+ expect(barLine[42]).toBe("╮");
153
+ });
154
+
155
+ test("three children produces wider bar with correct junction characters", () => {
156
+ // parent: x=42 → pcx=60
157
+ // child1: x=0 → cx=18
158
+ // child2: x=42 → cx=60 (same as parent)
159
+ // child3: x=84 → cx=102
160
+ const child1 = makeNode({ name: "c1", x: 0, y: 7, depth: 1 });
161
+ const child2 = makeNode({ name: "c2", x: 42, y: 7, depth: 1 });
162
+ const child3 = makeNode({ name: "c3", x: 84, y: 7, depth: 1 });
163
+ const parent = makeNode({
164
+ name: "parent",
165
+ x: 42,
166
+ y: 0,
167
+ depth: 0,
168
+ children: [child1, child2, child3],
169
+ });
170
+ const rowH = { 0: NODE_H };
171
+ // allCols=[60,18,60,102], minCol=18, maxCol=102, width=85
172
+ // childAtParent=true (child2.cx=60=pcx)
173
+ // parent: pcx=60, not at minCol/maxCol → '┼'
174
+ // child1: cx=18=minCol → '╭'
175
+ // child2: cx=60=pcx → skip (cx===pcx)
176
+ // child3: cx=102=maxCol → '╮'
177
+ const result = buildConnector(parent, rowH, mockTheme);
178
+
179
+ expect(result).not.toBeNull();
180
+ const r = result as ConnectorResult;
181
+ expect(r.col).toBe(18);
182
+ expect(r.row).toBe(4);
183
+ expect(r.width).toBe(85);
184
+ expect(r.height).toBe(3);
185
+
186
+ const lines = r.text.split("\n");
187
+ const barLine = lines[2]!;
188
+ // child1 junction at local col 0 (cx=18, minCol=18)
189
+ expect(barLine[0]).toBe("╭");
190
+ // parent junction at local col 42 (pcx=60, minCol=18)
191
+ expect(barLine[42]).toBe("┼");
192
+ // child3 junction at local col 84 (cx=102, minCol=18)
193
+ expect(barLine[84]).toBe("╮");
194
+ });
195
+
196
+ test("parent at left edge of bar gets ╰ junction", () => {
197
+ // parent: x=0 → pcx=18 (leftmost)
198
+ // child1: x=42 → cx=60
199
+ // child2: x=84 → cx=102
200
+ const child1 = makeNode({ name: "c1", x: 42, y: 7, depth: 1 });
201
+ const child2 = makeNode({ name: "c2", x: 84, y: 7, depth: 1 });
202
+ const parent = makeNode({
203
+ name: "parent",
204
+ x: 0,
205
+ y: 0,
206
+ depth: 0,
207
+ children: [child1, child2],
208
+ });
209
+ const rowH = { 0: NODE_H };
210
+ // allCols=[18,60,102], minCol=18=pcx, childAtParent=false → '╰'
211
+ const result = buildConnector(parent, rowH, mockTheme);
212
+
213
+ expect(result).not.toBeNull();
214
+ const r = result as ConnectorResult;
215
+ const barLine = r.text.split("\n")[2]!;
216
+ expect(barLine[0]).toBe("╰");
217
+ });
218
+
219
+ test("parent at right edge of bar gets ╯ junction", () => {
220
+ // parent: x=84 → pcx=102 (rightmost)
221
+ // child1: x=0 → cx=18
222
+ // child2: x=42 → cx=60
223
+ const child1 = makeNode({ name: "c1", x: 0, y: 7, depth: 1 });
224
+ const child2 = makeNode({ name: "c2", x: 42, y: 7, depth: 1 });
225
+ const parent = makeNode({
226
+ name: "parent",
227
+ x: 84,
228
+ y: 0,
229
+ depth: 0,
230
+ children: [child1, child2],
231
+ });
232
+ const rowH = { 0: NODE_H };
233
+ // allCols=[102,18,60], minCol=18, maxCol=102=pcx, childAtParent=false → '╯'
234
+ const result = buildConnector(parent, rowH, mockTheme);
235
+
236
+ expect(result).not.toBeNull();
237
+ const r = result as ConnectorResult;
238
+ const barLine = r.text.split("\n")[2]!;
239
+ // maxCol - minCol = 84 → local position of pcx = 102 - 18 = 84
240
+ expect(barLine[84]).toBe("╯");
241
+ });
242
+
243
+ test("parent in middle of bar gets ┴ junction", () => {
244
+ // parent: x=42 → pcx=60 (middle)
245
+ // child1: x=0 → cx=18
246
+ // child2: x=84 → cx=102
247
+ const child1 = makeNode({ name: "c1", x: 0, y: 7, depth: 1 });
248
+ const child2 = makeNode({ name: "c2", x: 84, y: 7, depth: 1 });
249
+ const parent = makeNode({
250
+ name: "parent",
251
+ x: 42,
252
+ y: 0,
253
+ depth: 0,
254
+ children: [child1, child2],
255
+ });
256
+ const rowH = { 0: NODE_H };
257
+ // allCols=[60,18,102], minCol=18, maxCol=102
258
+ // pcx=60: not minCol, not maxCol → childAtParent=false → '┴'
259
+ const result = buildConnector(parent, rowH, mockTheme);
260
+
261
+ expect(result).not.toBeNull();
262
+ const r = result as ConnectorResult;
263
+ const barLine = r.text.split("\n")[2]!;
264
+ // local col of pcx=60: 60-18=42
265
+ expect(barLine[42]).toBe("┴");
266
+ });
267
+
268
+ test("child at same column as parent produces ┼ junction", () => {
269
+ // parent: x=42 → pcx=60
270
+ // child1: x=0 → cx=18
271
+ // child2: x=42 → cx=60 (same as parent)
272
+ // child3: x=84 → cx=102
273
+ // childAtParent=true, pcx not at edges → '┼'
274
+ const child1 = makeNode({ name: "c1", x: 0, y: 7, depth: 1 });
275
+ const child2 = makeNode({ name: "c2", x: 42, y: 7, depth: 1 });
276
+ const child3 = makeNode({ name: "c3", x: 84, y: 7, depth: 1 });
277
+ const parent = makeNode({
278
+ name: "parent",
279
+ x: 42,
280
+ y: 0,
281
+ depth: 0,
282
+ children: [child1, child2, child3],
283
+ });
284
+ const rowH = { 0: NODE_H };
285
+ const result = buildConnector(parent, rowH, mockTheme);
286
+
287
+ expect(result).not.toBeNull();
288
+ const r = result as ConnectorResult;
289
+ const barLine = r.text.split("\n")[2]!;
290
+ // local col of pcx=60: 60-18=42
291
+ expect(barLine[42]).toBe("┼");
292
+ });
293
+
294
+ test("connector color matches theme.borderActive", () => {
295
+ const child = makeNode({ name: "child", x: 0, y: 7, depth: 1 });
296
+ const parent = makeNode({
297
+ name: "parent",
298
+ x: 0,
299
+ y: 0,
300
+ depth: 0,
301
+ children: [child],
302
+ });
303
+ const result = buildConnector(parent, { 0: NODE_H }, mockTheme);
304
+
305
+ expect(result).not.toBeNull();
306
+ expect((result as ConnectorResult).color).toBe("#aabbcc");
307
+ });
308
+
309
+ test("returns null when numRows is less than 1 (children too close to parent)", () => {
310
+ // child.y < parentBottom
311
+ const child = makeNode({ name: "child", x: 0, y: 3, depth: 1 });
312
+ const parent = makeNode({
313
+ name: "parent",
314
+ x: 0,
315
+ y: 0,
316
+ depth: 0,
317
+ children: [child],
318
+ });
319
+ // rowH[0]=4, parentBottom=4, firstChildRow=3 → numRows=-1 < 1
320
+ const result = buildConnector(parent, { 0: NODE_H }, mockTheme);
321
+ expect(result).toBeNull();
322
+ });
323
+
324
+ test("uses rowH override for node height when present", () => {
325
+ // rowH[0]=6 overrides NODE_H=4
326
+ const child = makeNode({ name: "child", x: 0, y: 10, depth: 1 });
327
+ const parent = makeNode({
328
+ name: "parent",
329
+ x: 0,
330
+ y: 0,
331
+ depth: 0,
332
+ children: [child],
333
+ });
334
+ const rowH = { 0: 6 };
335
+ // parentBottom = 0 + 6 = 6; firstChildRow=10; numRows=4
336
+ const result = buildConnector(parent, rowH, mockTheme);
337
+
338
+ expect(result).not.toBeNull();
339
+ const r = result as ConnectorResult;
340
+ expect(r.row).toBe(6);
341
+ expect(r.height).toBe(4);
342
+ expect(r.text).toBe("│\n│\n│\n│");
343
+ });
344
+
345
+ test("falls back to NODE_H when depth not in rowH", () => {
346
+ const child = makeNode({ name: "child", x: 0, y: 7, depth: 1 });
347
+ const parent = makeNode({
348
+ name: "parent",
349
+ x: 0,
350
+ y: 0,
351
+ depth: 0,
352
+ children: [child],
353
+ });
354
+ // rowH is empty — should fall back to NODE_H=4
355
+ const result = buildConnector(parent, {}, mockTheme);
356
+
357
+ expect(result).not.toBeNull();
358
+ const r = result as ConnectorResult;
359
+ // parentBottom = 0 + NODE_H = 4; numRows = 7 - 4 = 3
360
+ expect(r.row).toBe(4);
361
+ expect(r.height).toBe(3);
362
+ });
363
+ });
364
+
365
+ // ─── buildMergeConnector ─────────────────────────────────────────────────────
366
+
367
+ describe("buildMergeConnector", () => {
368
+ test("returns null when child has zero parents", () => {
369
+ const child = makeNode({ name: "child", x: 42, y: 14, depth: 1, parents: [] });
370
+ const result = buildMergeConnector(child, { 0: NODE_H }, {}, mockTheme);
371
+ expect(result).toBeNull();
372
+ });
373
+
374
+ test("returns null when child has a single parent", () => {
375
+ const child = makeNode({ name: "child", x: 42, y: 14, depth: 1, parents: ["a"] });
376
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
377
+ const result = buildMergeConnector(
378
+ child,
379
+ { 0: NODE_H },
380
+ { a: parentA },
381
+ mockTheme,
382
+ );
383
+ expect(result).toBeNull();
384
+ });
385
+
386
+ test("returns null when fewer than 2 parent nodes found in allNodes", () => {
387
+ // child claims two parents, but only one is present in allNodes
388
+ const child = makeNode({
389
+ name: "child",
390
+ x: 42,
391
+ y: 14,
392
+ depth: 1,
393
+ parents: ["a", "missing"],
394
+ });
395
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
396
+ const result = buildMergeConnector(
397
+ child,
398
+ { 0: NODE_H },
399
+ { a: parentA },
400
+ mockTheme,
401
+ );
402
+ expect(result).toBeNull();
403
+ });
404
+
405
+ test("two parents above produces horizontal bar with parent junctions and vertical stem to child", () => {
406
+ // parentA: x=0 → cx=18
407
+ // parentB: x=84 → cx=102
408
+ // child: x=42 → cx=60
409
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
410
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
411
+ const child = makeNode({
412
+ name: "child",
413
+ x: 42,
414
+ y: 14,
415
+ depth: 1,
416
+ parents: ["a", "b"],
417
+ });
418
+ const rowH = { 0: NODE_H };
419
+ // parentBottom = max(0+4, 0+4) = 4; childTop=14; numRows=10
420
+ // allCols=[18,102,60], minCol=18, maxCol=102, width=85
421
+ // barRow=0
422
+ // cx=18: hasUp=true, hasDown=false, isLeft=true → '╰'
423
+ // cx=102: hasUp=true, hasDown=false, isRight=true → '╯'
424
+ // cx=60: hasUp=false, hasDown=true, not at edges → '┬'
425
+ const result = buildMergeConnector(
426
+ child,
427
+ rowH,
428
+ { a: parentA, b: parentB },
429
+ mockTheme,
430
+ );
431
+
432
+ expect(result).not.toBeNull();
433
+ const r = result as ConnectorResult;
434
+ expect(r.col).toBe(18);
435
+ expect(r.row).toBe(4);
436
+ expect(r.width).toBe(85);
437
+ expect(r.height).toBe(10);
438
+ expect(r.color).toBe(mockTheme.borderActive);
439
+
440
+ const lines = r.text.split("\n");
441
+ expect(lines.length).toBe(10);
442
+
443
+ const barLine = lines[0]!;
444
+ // parentA junction at local col 0
445
+ expect(barLine[0]).toBe("╰");
446
+ // parentB junction at local col 84
447
+ expect(barLine[84]).toBe("╯");
448
+ // child junction at local col 42 (60-18=42)
449
+ expect(barLine[42]).toBe("┬");
450
+
451
+ // Vertical stem in rows 1..9 at local col 42 (childCx=60-18=42)
452
+ for (let r2 = 1; r2 < 10; r2++) {
453
+ expect(lines[r2]![42]).toBe("│");
454
+ }
455
+ });
456
+
457
+ test("child at same column as a parent produces ├ or ┤ junction", () => {
458
+ // parentA: x=0 → cx=18 (same as child)
459
+ // parentB: x=84 → cx=102
460
+ // child: x=0 → cx=18
461
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
462
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
463
+ const child = makeNode({
464
+ name: "child",
465
+ x: 0,
466
+ y: 14,
467
+ depth: 1,
468
+ parents: ["a", "b"],
469
+ });
470
+ const rowH = { 0: NODE_H };
471
+ // parentCxs=[18,102], childCx=18
472
+ // allCols=[18,102,18], minCol=18, maxCol=102, width=85
473
+ // parentSet={18,102}
474
+ // cx=18: hasUp=true, hasDown=true, isLeft=true → '├'
475
+ // cx=102: hasUp=true, hasDown=false, isRight=true → '╯'
476
+ // cx=18 again: same result → '├'
477
+ const result = buildMergeConnector(
478
+ child,
479
+ rowH,
480
+ { a: parentA, b: parentB },
481
+ mockTheme,
482
+ );
483
+
484
+ expect(result).not.toBeNull();
485
+ const r = result as ConnectorResult;
486
+ const barLine = r.text.split("\n")[0]!;
487
+ // local col of cx=18: 0
488
+ expect(barLine[0]).toBe("├");
489
+ // local col of cx=102: 84
490
+ expect(barLine[84]).toBe("╯");
491
+ });
492
+
493
+ test("three parents produces wider bar with correct junction characters", () => {
494
+ // parentA: x=0 → cx=18
495
+ // parentB: x=42 → cx=60 (same as child)
496
+ // parentC: x=84 → cx=102
497
+ // child: x=42 → cx=60
498
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
499
+ const parentB = makeNode({ name: "b", x: 42, y: 0, depth: 0 });
500
+ const parentC = makeNode({ name: "c", x: 84, y: 0, depth: 0 });
501
+ const child = makeNode({
502
+ name: "child",
503
+ x: 42,
504
+ y: 14,
505
+ depth: 1,
506
+ parents: ["a", "b", "c"],
507
+ });
508
+ const rowH = { 0: NODE_H };
509
+ // parentCxs=[18,60,102], childCx=60
510
+ // allCols=[18,60,102,60], minCol=18, maxCol=102, width=85
511
+ // parentSet={18,60,102}
512
+ // cx=18: hasUp=true, hasDown=false, isLeft=true → '╰'
513
+ // cx=60: hasUp=true, hasDown=true, not edges → '┼'
514
+ // cx=102: hasUp=true, hasDown=false, isRight=true → '╯'
515
+ const result = buildMergeConnector(
516
+ child,
517
+ rowH,
518
+ { a: parentA, b: parentB, c: parentC },
519
+ mockTheme,
520
+ );
521
+
522
+ expect(result).not.toBeNull();
523
+ const r = result as ConnectorResult;
524
+ expect(r.col).toBe(18);
525
+ expect(r.width).toBe(85);
526
+
527
+ const barLine = r.text.split("\n")[0]!;
528
+ expect(barLine[0]).toBe("╰");
529
+ // local col of cx=60: 60-18=42
530
+ expect(barLine[42]).toBe("┼");
531
+ expect(barLine[84]).toBe("╯");
532
+ });
533
+
534
+ test("correct positioning: row equals max parent bottom, height equals childTop minus parentBottom", () => {
535
+ // parentA at y=0, depth=0, rowH[0]=6 → bottom at 6
536
+ // parentB at y=2, depth=0, rowH[0]=6 → bottom at 8 (max)
537
+ // child.y = 20 → numRows = 20 - 8 = 12
538
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
539
+ const parentB = makeNode({ name: "b", x: 84, y: 2, depth: 0 });
540
+ const child = makeNode({
541
+ name: "child",
542
+ x: 42,
543
+ y: 20,
544
+ depth: 1,
545
+ parents: ["a", "b"],
546
+ });
547
+ const rowH = { 0: 6 };
548
+
549
+ const result = buildMergeConnector(
550
+ child,
551
+ rowH,
552
+ { a: parentA, b: parentB },
553
+ mockTheme,
554
+ );
555
+
556
+ expect(result).not.toBeNull();
557
+ const r = result as ConnectorResult;
558
+ // parentBottom = max(0+6, 2+6) = 8
559
+ expect(r.row).toBe(8);
560
+ // height = 20 - 8 = 12
561
+ expect(r.height).toBe(12);
562
+ });
563
+
564
+ test("connector color matches theme.borderActive", () => {
565
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
566
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
567
+ const child = makeNode({
568
+ name: "child",
569
+ x: 42,
570
+ y: 14,
571
+ depth: 1,
572
+ parents: ["a", "b"],
573
+ });
574
+ const result = buildMergeConnector(
575
+ child,
576
+ { 0: NODE_H },
577
+ { a: parentA, b: parentB },
578
+ mockTheme,
579
+ );
580
+
581
+ expect(result).not.toBeNull();
582
+ expect((result as ConnectorResult).color).toBe("#aabbcc");
583
+ });
584
+
585
+ test("returns null when numRows is less than 1 (child too close to parents)", () => {
586
+ // parents bottom at 4, child.y=3 → numRows=-1
587
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
588
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
589
+ const child = makeNode({
590
+ name: "child",
591
+ x: 42,
592
+ y: 3,
593
+ depth: 1,
594
+ parents: ["a", "b"],
595
+ });
596
+ const result = buildMergeConnector(
597
+ child,
598
+ { 0: NODE_H },
599
+ { a: parentA, b: parentB },
600
+ mockTheme,
601
+ );
602
+ expect(result).toBeNull();
603
+ });
604
+
605
+ test("uses NODE_H as fallback when depth not in rowH", () => {
606
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
607
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
608
+ const child = makeNode({
609
+ name: "child",
610
+ x: 42,
611
+ y: 14,
612
+ depth: 1,
613
+ parents: ["a", "b"],
614
+ });
615
+ // empty rowH — falls back to NODE_H=4
616
+ const result = buildMergeConnector(
617
+ child,
618
+ {},
619
+ { a: parentA, b: parentB },
620
+ mockTheme,
621
+ );
622
+
623
+ expect(result).not.toBeNull();
624
+ const r = result as ConnectorResult;
625
+ // parentBottom = 0 + NODE_H = 4
626
+ expect(r.row).toBe(4);
627
+ expect(r.height).toBe(10); // 14 - 4
628
+ });
629
+
630
+ test("child at same column as rightmost parent produces ┤ junction at right edge", () => {
631
+ // parentA: x=0 → cx=18
632
+ // parentB: x=84 → cx=102 (same as child)
633
+ // child: x=84 → cx=102
634
+ const parentA = makeNode({ name: "a", x: 0, y: 0, depth: 0 });
635
+ const parentB = makeNode({ name: "b", x: 84, y: 0, depth: 0 });
636
+ const child = makeNode({
637
+ name: "child",
638
+ x: 84,
639
+ y: 14,
640
+ depth: 1,
641
+ parents: ["a", "b"],
642
+ });
643
+ const rowH = { 0: NODE_H };
644
+ // parentCxs=[18,102], childCx=102
645
+ // allCols=[18,102,102], minCol=18, maxCol=102, width=85
646
+ // cx=18: hasUp=true, hasDown=false, isLeft=true → '╰'
647
+ // cx=102: hasUp=true, hasDown=true, isRight=true → '┤'
648
+ const result = buildMergeConnector(
649
+ child,
650
+ rowH,
651
+ { a: parentA, b: parentB },
652
+ mockTheme,
653
+ );
654
+
655
+ expect(result).not.toBeNull();
656
+ const r = result as ConnectorResult;
657
+ const barLine = r.text.split("\n")[0]!;
658
+ expect(barLine[0]).toBe("╰");
659
+ expect(barLine[84]).toBe("┤");
660
+ });
661
+ });