agentweaver 0.1.9 → 0.1.10

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 (26) hide show
  1. package/README.md +60 -28
  2. package/dist/artifacts.js +1 -1
  3. package/dist/errors.js +7 -0
  4. package/dist/index.js +66 -34
  5. package/dist/interactive-ui.js +351 -44
  6. package/dist/pipeline/declarative-flows.js +7 -4
  7. package/dist/pipeline/flow-catalog.js +28 -21
  8. package/dist/pipeline/flow-specs/opencode/auto-opencode.json +1365 -0
  9. package/dist/pipeline/flow-specs/opencode/bugz/bug-analyze-opencode.json +382 -0
  10. package/dist/pipeline/flow-specs/opencode/bugz/bug-fix-opencode.json +56 -0
  11. package/dist/pipeline/flow-specs/opencode/gitlab/gitlab-diff-review-opencode.json +308 -0
  12. package/dist/pipeline/flow-specs/opencode/gitlab/gitlab-review-opencode.json +437 -0
  13. package/dist/pipeline/flow-specs/opencode/gitlab/mr-description-opencode.json +117 -0
  14. package/dist/pipeline/flow-specs/opencode/go/run-go-linter-loop-opencode.json +321 -0
  15. package/dist/pipeline/flow-specs/opencode/go/run-go-tests-loop-opencode.json +321 -0
  16. package/dist/pipeline/flow-specs/opencode/implement-opencode.json +64 -0
  17. package/dist/pipeline/flow-specs/{plan-opencode.json → opencode/plan-opencode.json} +4 -4
  18. package/dist/pipeline/flow-specs/opencode/review/review-fix-opencode.json +209 -0
  19. package/dist/pipeline/flow-specs/opencode/review/review-opencode.json +452 -0
  20. package/dist/pipeline/flow-specs/opencode/task-describe-opencode.json +148 -0
  21. package/dist/pipeline/spec-loader.js +18 -7
  22. package/dist/runtime/process-runner.js +45 -1
  23. package/package.json +1 -1
  24. package/dist/pipeline/flow-specs/preflight.json +0 -206
  25. package/dist/pipeline/flow-specs/run-linter-loop.json +0 -155
  26. package/dist/pipeline/flow-specs/run-tests-loop.json +0 -155
@@ -2,10 +2,104 @@ import path from "node:path";
2
2
  import blessed from "neo-blessed";
3
3
  import { renderMarkdownToTerminal } from "./markdown.js";
4
4
  import { setOutputAdapter, stripAnsi } from "./tui.js";
5
- import { TaskRunnerError } from "./errors.js";
5
+ import { FlowInterruptedError, TaskRunnerError } from "./errors.js";
6
6
  import { buildInitialUserInputValues, validateUserInputValues, } from "./user-input.js";
7
7
  const CONFIRM_MIN_WIDTH = 44;
8
8
  const CONFIRM_MIN_HEIGHT = 8;
9
+ function compareTreeNames(left, right) {
10
+ return left.localeCompare(right, "ru");
11
+ }
12
+ function makeFolderKey(pathSegments) {
13
+ return `folder:${pathSegments.join("/")}`;
14
+ }
15
+ function makeFlowKey(flowId) {
16
+ return `flow:${flowId}`;
17
+ }
18
+ function buildFlowTree(flows) {
19
+ const roots = new Map();
20
+ const ensureFolder = (pathSegments) => {
21
+ const firstSegment = pathSegments[0];
22
+ if (!firstSegment) {
23
+ throw new Error("Flow tree folder path cannot be empty.");
24
+ }
25
+ const rootFolder = roots.get(firstSegment);
26
+ let currentFolder;
27
+ if (rootFolder) {
28
+ currentFolder = rootFolder;
29
+ }
30
+ else {
31
+ currentFolder = {
32
+ kind: "folder",
33
+ key: makeFolderKey([firstSegment]),
34
+ name: firstSegment,
35
+ pathSegments: [firstSegment],
36
+ children: [],
37
+ };
38
+ roots.set(firstSegment, currentFolder);
39
+ }
40
+ for (let index = 1; index < pathSegments.length; index += 1) {
41
+ const segment = pathSegments[index] ?? "";
42
+ const folderPath = pathSegments.slice(0, index + 1);
43
+ let nextFolder = currentFolder.children.find((child) => child.kind === "folder" && child.name === segment);
44
+ if (!nextFolder) {
45
+ nextFolder = {
46
+ kind: "folder",
47
+ key: makeFolderKey(folderPath),
48
+ name: segment,
49
+ pathSegments: folderPath,
50
+ children: [],
51
+ };
52
+ currentFolder.children.push(nextFolder);
53
+ }
54
+ currentFolder = nextFolder;
55
+ }
56
+ return currentFolder;
57
+ };
58
+ for (const flow of flows) {
59
+ if (flow.treePath.length === 0) {
60
+ continue;
61
+ }
62
+ const folderPath = flow.treePath.slice(0, -1);
63
+ const leafName = flow.treePath[flow.treePath.length - 1] ?? flow.id;
64
+ const parent = ensureFolder(folderPath);
65
+ parent.children.push({
66
+ kind: "flow",
67
+ key: makeFlowKey(flow.id),
68
+ name: leafName,
69
+ pathSegments: [...flow.treePath],
70
+ flow,
71
+ });
72
+ }
73
+ const sortNodes = (nodes) => [...nodes]
74
+ .sort((left, right) => {
75
+ if (left.kind !== right.kind) {
76
+ return left.kind === "folder" ? -1 : 1;
77
+ }
78
+ return compareTreeNames(left.name, right.name);
79
+ })
80
+ .map((node) => node.kind === "folder"
81
+ ? {
82
+ ...node,
83
+ children: sortNodes(node.children),
84
+ }
85
+ : node);
86
+ const orderedRootNames = ["custom", "default"];
87
+ const sortedRoots = [...roots.values()].sort((left, right) => {
88
+ const leftIndex = orderedRootNames.indexOf(left.name);
89
+ const rightIndex = orderedRootNames.indexOf(right.name);
90
+ if (leftIndex !== -1 || rightIndex !== -1) {
91
+ if (leftIndex === -1) {
92
+ return 1;
93
+ }
94
+ if (rightIndex === -1) {
95
+ return -1;
96
+ }
97
+ return leftIndex - rightIndex;
98
+ }
99
+ return compareTreeNames(left.name, right.name);
100
+ });
101
+ return sortNodes(sortedRoots);
102
+ }
9
103
  export class InteractiveUi {
10
104
  options;
11
105
  screen;
@@ -21,9 +115,13 @@ export class InteractiveUi {
21
115
  confirm;
22
116
  formModal;
23
117
  flowMap;
118
+ flowTree;
119
+ expandedFlowFolders = new Set();
120
+ visibleFlowItems = [];
24
121
  busy = false;
25
122
  currentFlowId = null;
26
123
  selectedFlowId;
124
+ selectedFlowItemKey;
27
125
  summaryText = "";
28
126
  focusedPane = "flows";
29
127
  currentNode = null;
@@ -50,7 +148,10 @@ export class InteractiveUi {
50
148
  throw new Error("Interactive UI requires at least one flow.");
51
149
  }
52
150
  this.flowMap = new Map(options.flows.map((flow) => [flow.id, flow]));
151
+ this.flowTree = buildFlowTree(options.flows);
53
152
  this.selectedFlowId = options.flows[0]?.id ?? "auto";
153
+ this.visibleFlowItems = this.computeVisibleFlowItems();
154
+ this.selectedFlowItemKey = this.visibleFlowItems[0]?.key ?? makeFlowKey(this.selectedFlowId);
54
155
  this.scopeKey = options.scopeKey;
55
156
  this.jiraIssueKey = options.jiraIssueKey ?? null;
56
157
  this.summaryVisible = options.summaryText.trim().length > 0;
@@ -338,6 +439,10 @@ export class InteractiveUi {
338
439
  this.requestRender();
339
440
  });
340
441
  this.screen.key(["escape"], () => {
442
+ if (this.busy && this.confirm.hidden && this.help.hidden) {
443
+ this.openInterruptConfirm();
444
+ return;
445
+ }
341
446
  if (this.hasActiveForm()) {
342
447
  this.cancelActiveForm();
343
448
  return;
@@ -375,11 +480,15 @@ export class InteractiveUi {
375
480
  if (this.hasActiveForm()) {
376
481
  return;
377
482
  }
378
- const flow = this.options.flows[index];
379
- if (!flow) {
483
+ const selectedItem = this.visibleFlowItems[index];
484
+ if (!selectedItem) {
380
485
  return;
381
486
  }
382
- this.selectedFlowId = flow.id;
487
+ this.selectedFlowItemKey = selectedItem.key;
488
+ if (selectedItem.kind === "flow") {
489
+ this.selectedFlowId = selectedItem.flow.id;
490
+ }
491
+ this.updateHeader();
383
492
  this.renderDescription();
384
493
  this.renderProgress();
385
494
  this.requestRender();
@@ -388,8 +497,25 @@ export class InteractiveUi {
388
497
  if (this.busy || this.confirm.visible || !this.help.hidden || this.hasActiveForm()) {
389
498
  return;
390
499
  }
500
+ const selectedItem = this.selectedFlowTreeItem();
501
+ if (selectedItem?.kind === "folder") {
502
+ this.toggleFlowFolder(selectedItem.key);
503
+ return;
504
+ }
391
505
  await this.openConfirm();
392
506
  });
507
+ this.flowList.key(["right"], () => {
508
+ if (this.hasActiveForm()) {
509
+ return;
510
+ }
511
+ this.expandSelectedFlowFolder();
512
+ });
513
+ this.flowList.key(["left"], () => {
514
+ if (this.hasActiveForm()) {
515
+ return;
516
+ }
517
+ this.collapseSelectedFlowFolderOrSelectParent();
518
+ });
393
519
  this.flowList.key(["pageup"], () => {
394
520
  if (this.hasActiveForm()) {
395
521
  return;
@@ -475,20 +601,29 @@ export class InteractiveUi {
475
601
  this.requestRender();
476
602
  });
477
603
  this.confirm.key(["enter"], async () => {
478
- if (this.busy || this.confirm.hidden || this.hasActiveForm() || !this.confirmSession) {
604
+ if (this.confirm.hidden || !this.confirmSession) {
479
605
  return;
480
606
  }
481
- const { flowId, selectedAction } = this.confirmSession;
607
+ if (this.hasActiveForm() && this.confirmSession.kind !== "interrupt") {
608
+ return;
609
+ }
610
+ const { flowId, selectedAction, kind } = this.confirmSession;
482
611
  if (selectedAction === "cancel") {
483
612
  this.closeConfirm();
484
613
  return;
485
614
  }
615
+ if (kind === "interrupt") {
616
+ this.closeConfirm();
617
+ await this.options.onInterrupt(flowId);
618
+ return;
619
+ }
486
620
  this.closeConfirm();
487
621
  this.setBusy(true, flowId);
488
622
  this.clearFlowFailure(flowId);
489
623
  this.setFlowDisplayState(flowId, null);
490
624
  try {
491
- await this.options.onRun(flowId, selectedAction === "ok" ? "restart" : selectedAction);
625
+ const launchMode = selectedAction === "resume" ? "resume" : "restart";
626
+ await this.options.onRun(flowId, launchMode);
492
627
  }
493
628
  finally {
494
629
  this.setBusy(false);
@@ -547,7 +682,7 @@ export class InteractiveUi {
547
682
  else {
548
683
  this.log.focus();
549
684
  }
550
- this.footer.setContent(` Focus: ${pane} | Up/Down: select flow | Enter: confirm run | h: help | Esc: close | Tab: switch pane | q: exit `);
685
+ this.footer.setContent(` Focus: ${pane} | Up/Down: select | Left/Right: fold | Enter: toggle/run | h: help | Esc: close/interrupt | Tab: switch pane | q: exit `);
551
686
  this.requestRender();
552
687
  }
553
688
  renderStaticContent() {
@@ -555,8 +690,7 @@ export class InteractiveUi {
555
690
  this.summaryVisible = this.summaryText.length > 0;
556
691
  this.applyRightPaneLayout();
557
692
  this.updateHeader();
558
- this.flowList.setItems(this.options.flows.map((flow) => this.renderFlowListLabel(flow)));
559
- this.flowList.select(this.options.flows.findIndex((flow) => flow.id === this.selectedFlowId));
693
+ this.renderFlowTreeList();
560
694
  this.renderDescription();
561
695
  this.renderSummary();
562
696
  this.renderProgress();
@@ -564,22 +698,24 @@ export class InteractiveUi {
564
698
  "AgentWeaver interactive mode",
565
699
  "",
566
700
  "Клавиши:",
567
- "Up / Down выбрать flow",
568
- "Enter открыть подтверждение запуска",
701
+ "Up / Down выбрать папку или flow",
702
+ "Right раскрыть папку",
703
+ "Left свернуть папку или перейти к родителю",
704
+ "Enter раскрыть папку или открыть запуск flow",
569
705
  "Enter подтвердить запуск в модалке",
570
- "Esc закрыть help или модалку",
571
- "h / F1 открыть или закрыть help",
706
+ "Esc закрыть help/модалку или прервать running flow",
707
+ "F1 открыть или закрыть help",
572
708
  "Tab переключить pane",
573
709
  "Ctrl+L очистить лог",
574
710
  "q / Ctrl+C выйти",
575
711
  "",
576
712
  "Доступные flow:",
577
- ...this.options.flows.map((flow) => flow.label),
713
+ ...this.options.flows.map((flow) => flow.treePath.join("/")),
578
714
  ].join("\n")));
579
- this.footer.setContent(" Up/Down: select flow | Enter: confirm run | h: help | Tab: switch pane | q: exit ");
715
+ this.footer.setContent(" Up/Down: select | Left/Right: fold | Enter: toggle/run | Esc: close/interrupt | h: help | Tab: switch pane | q: exit ");
580
716
  }
581
717
  updateHeader() {
582
- const current = this.currentFlowId ?? this.selectedFlowId;
718
+ const current = this.currentFlowId ?? this.selectedHeaderLabel();
583
719
  const pathParts = this.options.cwd.split(path.sep).filter(Boolean);
584
720
  const folderName = pathParts.slice(-3).join("/") || this.options.cwd;
585
721
  const branchLabel = this.options.gitBranchName ? this.options.gitBranchName : "detached-head";
@@ -626,7 +762,7 @@ export class InteractiveUi {
626
762
  renderActiveForm() {
627
763
  if (!this.activeFormSession) {
628
764
  this.formModal.hide();
629
- this.footer.setContent(" Up/Down: select flow | Enter: confirm run | h: help | Tab: switch pane | q: exit ");
765
+ this.footer.setContent(" Up/Down: select | Left/Right: fold | Enter: toggle/run | Esc: close/interrupt | h: help | Tab: switch pane | q: exit ");
630
766
  this.requestRender();
631
767
  return;
632
768
  }
@@ -796,6 +932,17 @@ export class InteractiveUi {
796
932
  session.reject(new TaskRunnerError(`User cancelled form '${session.form.formId}'.`));
797
933
  this.renderActiveForm();
798
934
  }
935
+ interruptActiveForm(message = "Flow interrupted by user.") {
936
+ if (!this.activeFormSession) {
937
+ return;
938
+ }
939
+ const session = this.activeFormSession;
940
+ this.activeFormSession = null;
941
+ this.formModal.hide();
942
+ this.focusPane("flows");
943
+ session.reject(new FlowInterruptedError(message));
944
+ this.renderActiveForm();
945
+ }
799
946
  handleActiveFormKey(ch, key) {
800
947
  const field = this.currentFormField();
801
948
  if (!field) {
@@ -855,22 +1002,33 @@ export class InteractiveUi {
855
1002
  }
856
1003
  }
857
1004
  renderDescription() {
858
- const flow = this.flowMap.get(this.selectedFlowId);
859
- const description = flow?.description?.trim() || "Описание для этого flow пока не задано.";
1005
+ const selectedItem = this.selectedFlowTreeItem();
1006
+ if (!selectedItem) {
1007
+ this.description.setContent("Flow structure is not available.");
1008
+ return;
1009
+ }
1010
+ if (selectedItem.kind === "folder") {
1011
+ const kindLabel = selectedItem.pathSegments[0] === "custom" ? "project-local" : "built-in";
1012
+ const folderDescription = [
1013
+ `Папка flow \`${selectedItem.pathSegments.join("/")}\`.`,
1014
+ "",
1015
+ `Источник: ${kindLabel}`,
1016
+ `Статус: ${this.expandedFlowFolders.has(selectedItem.key) ? "развёрнута" : "свёрнута"}`,
1017
+ ].join("\n");
1018
+ this.description.setContent(renderMarkdownToTerminal(stripAnsi(folderDescription)));
1019
+ return;
1020
+ }
1021
+ const { flow } = selectedItem;
1022
+ const description = flow.description?.trim() || "Описание для этого flow пока не задано.";
860
1023
  const details = [
861
- flow ? `Источник: ${flow.source === "project-local" ? "project-local" : "built-in"}` : "",
862
- flow?.source === "project-local" && flow.sourcePath ? `Файл: ${flow.sourcePath}` : "",
1024
+ `Путь: ${flow.treePath.join("/")}`,
1025
+ `Источник: ${flow.source === "project-local" ? "project-local" : "built-in"}`,
1026
+ flow.source === "project-local" && flow.sourcePath ? `Файл: ${flow.sourcePath}` : "",
863
1027
  ]
864
1028
  .filter((line) => line.length > 0)
865
1029
  .join("\n");
866
1030
  this.description.setContent(renderMarkdownToTerminal(stripAnsi(details ? `${description}\n\n${details}` : description)));
867
1031
  }
868
- renderFlowListLabel(flow) {
869
- if (flow.source === "project-local") {
870
- return `{yellow-fg}${flow.label}{/yellow-fg}`;
871
- }
872
- return flow.label;
873
- }
874
1032
  createAdapter() {
875
1033
  return {
876
1034
  writeStdout: (text) => {
@@ -897,13 +1055,19 @@ export class InteractiveUi {
897
1055
  return this.currentFlowId ?? this.selectedFlowId;
898
1056
  }
899
1057
  progressFlowDefinition() {
900
- const preferredFlowId = this.busy ? this.activeFlowId() : this.selectedFlowId;
901
- return this.flowMap.get(preferredFlowId);
1058
+ if (this.busy) {
1059
+ return this.flowMap.get(this.activeFlowId());
1060
+ }
1061
+ const selectedItem = this.selectedFlowTreeItem();
1062
+ if (selectedItem?.kind === "flow") {
1063
+ return selectedItem.flow;
1064
+ }
1065
+ return undefined;
902
1066
  }
903
1067
  renderProgress() {
904
1068
  const flow = this.progressFlowDefinition();
905
1069
  if (!flow) {
906
- this.progress.setContent("Flow structure is not available.");
1070
+ this.progress.setContent("Выберите конкретный flow в дереве, чтобы увидеть его прогресс.");
907
1071
  return;
908
1072
  }
909
1073
  const flowState = this.flowState.flowId === flow.id
@@ -1165,34 +1329,47 @@ export class InteractiveUi {
1165
1329
  if (!this.confirmSession) {
1166
1330
  return;
1167
1331
  }
1168
- const actions = this.confirmSession.resumeAvailable
1169
- ? ["resume", "restart", "cancel"]
1170
- : this.confirmSession.hasExistingState
1171
- ? ["restart", "cancel"]
1172
- : ["ok", "cancel"];
1332
+ const actions = this.confirmActions();
1173
1333
  const currentIndex = actions.indexOf(this.confirmSession.selectedAction);
1174
1334
  const nextIndex = (currentIndex + delta + actions.length) % actions.length;
1175
1335
  this.confirmSession.selectedAction = (actions[nextIndex] ?? "cancel");
1176
1336
  this.renderConfirm();
1177
1337
  }
1338
+ confirmActions() {
1339
+ if (!this.confirmSession) {
1340
+ return ["cancel"];
1341
+ }
1342
+ if (this.confirmSession.kind === "interrupt") {
1343
+ return ["stop", "cancel"];
1344
+ }
1345
+ return this.confirmSession.resumeAvailable
1346
+ ? ["resume", "restart", "cancel"]
1347
+ : this.confirmSession.hasExistingState
1348
+ ? ["restart", "cancel"]
1349
+ : ["ok", "cancel"];
1350
+ }
1178
1351
  renderConfirm() {
1179
1352
  const session = this.confirmSession;
1180
1353
  if (!session) {
1181
1354
  return;
1182
1355
  }
1183
1356
  const flow = this.flowMap.get(session.flowId);
1184
- const actions = session.resumeAvailable
1185
- ? ["resume", "restart", "cancel"]
1186
- : session.hasExistingState
1187
- ? ["restart", "cancel"]
1188
- : ["ok", "cancel"];
1357
+ const actions = this.confirmActions();
1189
1358
  const actionLabels = actions
1190
1359
  .map((action) => {
1191
- const label = action === "resume" ? "Resume" : action === "restart" ? "Restart" : action === "ok" ? "OK" : "Cancel";
1360
+ const label = action === "stop"
1361
+ ? "Stop"
1362
+ : action === "resume"
1363
+ ? "Resume"
1364
+ : action === "restart"
1365
+ ? "Restart"
1366
+ : action === "ok"
1367
+ ? "OK"
1368
+ : "Cancel";
1192
1369
  return session.selectedAction === action ? `[ ${label} ]` : ` ${label} `;
1193
1370
  })
1194
1371
  .join(" ");
1195
- const lines = [`Run flow "${flow?.label ?? session.flowId}"?`];
1372
+ const lines = [session.kind === "interrupt" ? `Interrupt flow "${flow?.label ?? session.flowId}"?` : `Run flow "${flow?.label ?? session.flowId}"?`];
1196
1373
  if (session.details?.trim()) {
1197
1374
  lines.push("", session.details.trim());
1198
1375
  }
@@ -1214,12 +1391,17 @@ export class InteractiveUi {
1214
1391
  this.requestRender();
1215
1392
  }
1216
1393
  async openConfirm() {
1217
- const flow = this.flowMap.get(this.selectedFlowId);
1394
+ const selectedItem = this.selectedFlowTreeItem();
1395
+ if (!selectedItem || selectedItem.kind !== "flow") {
1396
+ return;
1397
+ }
1398
+ const flow = selectedItem.flow;
1218
1399
  if (!flow) {
1219
1400
  return;
1220
1401
  }
1221
1402
  const confirmation = await this.options.getRunConfirmation(flow.id);
1222
1403
  this.confirmSession = {
1404
+ kind: "run",
1223
1405
  flowId: flow.id,
1224
1406
  resumeAvailable: confirmation.resumeAvailable,
1225
1407
  hasExistingState: confirmation.hasExistingState,
@@ -1228,6 +1410,21 @@ export class InteractiveUi {
1228
1410
  };
1229
1411
  this.renderConfirm();
1230
1412
  }
1413
+ openInterruptConfirm() {
1414
+ const flowId = this.currentFlowId;
1415
+ if (!flowId || this.confirm.visible) {
1416
+ return;
1417
+ }
1418
+ this.confirmSession = {
1419
+ kind: "interrupt",
1420
+ flowId,
1421
+ resumeAvailable: true,
1422
+ hasExistingState: true,
1423
+ details: "Текущий flow будет остановлен. Состояние сохранится, и его можно будет продолжить через Resume.",
1424
+ selectedAction: "stop",
1425
+ };
1426
+ this.renderConfirm();
1427
+ }
1231
1428
  closeConfirm() {
1232
1429
  this.confirmSession = null;
1233
1430
  this.confirm.hide();
@@ -1364,6 +1561,116 @@ export class InteractiveUi {
1364
1561
  this.renderProgress();
1365
1562
  this.requestRender();
1366
1563
  }
1564
+ computeVisibleFlowItems() {
1565
+ const items = [];
1566
+ const walk = (nodes, depth) => {
1567
+ for (const node of nodes) {
1568
+ if (node.kind === "folder") {
1569
+ items.push({
1570
+ kind: "folder",
1571
+ key: node.key,
1572
+ name: node.name,
1573
+ depth,
1574
+ pathSegments: [...node.pathSegments],
1575
+ });
1576
+ if (this.expandedFlowFolders.has(node.key)) {
1577
+ walk(node.children, depth + 1);
1578
+ }
1579
+ continue;
1580
+ }
1581
+ items.push({
1582
+ kind: "flow",
1583
+ key: node.key,
1584
+ name: node.name,
1585
+ depth,
1586
+ pathSegments: [...node.pathSegments],
1587
+ flow: node.flow,
1588
+ });
1589
+ }
1590
+ };
1591
+ walk(this.flowTree, 0);
1592
+ return items;
1593
+ }
1594
+ selectedFlowTreeItem() {
1595
+ return this.visibleFlowItems.find((item) => item.key === this.selectedFlowItemKey);
1596
+ }
1597
+ selectedHeaderLabel() {
1598
+ const selectedItem = this.selectedFlowTreeItem();
1599
+ if (!selectedItem) {
1600
+ return this.selectedFlowId;
1601
+ }
1602
+ return selectedItem.kind === "folder" ? selectedItem.pathSegments.join("/") : selectedItem.flow.label;
1603
+ }
1604
+ refreshVisibleFlowItems() {
1605
+ this.visibleFlowItems = this.computeVisibleFlowItems();
1606
+ if (!this.visibleFlowItems.some((item) => item.key === this.selectedFlowItemKey)) {
1607
+ this.selectedFlowItemKey = this.visibleFlowItems[0]?.key ?? makeFlowKey(this.selectedFlowId);
1608
+ }
1609
+ const selectedItem = this.selectedFlowTreeItem();
1610
+ if (selectedItem?.kind === "flow") {
1611
+ this.selectedFlowId = selectedItem.flow.id;
1612
+ }
1613
+ }
1614
+ renderFlowTreeList() {
1615
+ this.refreshVisibleFlowItems();
1616
+ this.flowList.setItems(this.visibleFlowItems.map((item) => this.renderFlowTreeLabel(item)));
1617
+ const selectedIndex = this.visibleFlowItems.findIndex((item) => item.key === this.selectedFlowItemKey);
1618
+ this.flowList.select(selectedIndex >= 0 ? selectedIndex : 0);
1619
+ }
1620
+ renderFlowTreeLabel(item) {
1621
+ const indent = " ".repeat(item.depth);
1622
+ if (item.kind === "folder") {
1623
+ const expanded = this.expandedFlowFolders.has(item.key);
1624
+ const color = "cyan";
1625
+ return `${indent}{${color}-fg}${expanded ? "▾" : "▸"} ${item.name}{/${color}-fg}`;
1626
+ }
1627
+ const color = "white";
1628
+ return `${indent}{${color}-fg}• ${item.name}{/${color}-fg}`;
1629
+ }
1630
+ toggleFlowFolder(folderKey) {
1631
+ if (this.expandedFlowFolders.has(folderKey)) {
1632
+ this.expandedFlowFolders.delete(folderKey);
1633
+ }
1634
+ else {
1635
+ this.expandedFlowFolders.add(folderKey);
1636
+ }
1637
+ this.renderFlowTreeList();
1638
+ this.renderDescription();
1639
+ this.renderProgress();
1640
+ this.updateHeader();
1641
+ this.requestRender();
1642
+ }
1643
+ expandSelectedFlowFolder() {
1644
+ const selectedItem = this.selectedFlowTreeItem();
1645
+ if (!selectedItem || selectedItem.kind !== "folder" || this.expandedFlowFolders.has(selectedItem.key)) {
1646
+ return;
1647
+ }
1648
+ this.toggleFlowFolder(selectedItem.key);
1649
+ }
1650
+ collapseSelectedFlowFolderOrSelectParent() {
1651
+ const selectedItem = this.selectedFlowTreeItem();
1652
+ if (!selectedItem) {
1653
+ return;
1654
+ }
1655
+ if (selectedItem.kind === "folder" && this.expandedFlowFolders.has(selectedItem.key)) {
1656
+ this.toggleFlowFolder(selectedItem.key);
1657
+ return;
1658
+ }
1659
+ const parentPath = selectedItem.pathSegments.slice(0, -1);
1660
+ if (parentPath.length === 0) {
1661
+ return;
1662
+ }
1663
+ const parentKey = makeFolderKey(parentPath);
1664
+ if (!this.visibleFlowItems.some((item) => item.key === parentKey)) {
1665
+ return;
1666
+ }
1667
+ this.selectedFlowItemKey = parentKey;
1668
+ this.renderFlowTreeList();
1669
+ this.renderDescription();
1670
+ this.renderProgress();
1671
+ this.updateHeader();
1672
+ this.requestRender();
1673
+ }
1367
1674
  updateRunningPanel() {
1368
1675
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1369
1676
  const running = this.busy || this.currentNode !== null || this.currentExecutor !== null;
@@ -39,18 +39,21 @@ export function loadDeclarativeFlow(flow) {
39
39
  }
40
40
  export function resolveNamedDeclarativeFlowRef(fileName, cwd) {
41
41
  const projectMatches = listProjectFlowSpecFiles(cwd).filter((candidate) => path.basename(candidate) === fileName);
42
- const builtInExists = listBuiltInFlowSpecFiles().includes(fileName);
43
- if (projectMatches.length > 0 && builtInExists) {
42
+ const builtInMatches = listBuiltInFlowSpecFiles().filter((candidate) => path.basename(candidate) === fileName);
43
+ if (projectMatches.length > 0 && builtInMatches.length > 0) {
44
44
  throw new Error(`Ambiguous nested flow '${fileName}': both built-in and project-local specs exist in ${projectFlowSpecsDir(cwd)}.`);
45
45
  }
46
46
  if (projectMatches.length > 1) {
47
47
  throw new Error(`Ambiguous project-local flow '${fileName}' in ${projectFlowSpecsDir(cwd)}.`);
48
48
  }
49
+ if (builtInMatches.length > 1) {
50
+ throw new Error(`Ambiguous built-in flow '${fileName}'. Use unique nested flow file names.`);
51
+ }
49
52
  if (projectMatches[0]) {
50
53
  return { source: "project-local", filePath: projectMatches[0] };
51
54
  }
52
- if (builtInExists) {
53
- return { source: "built-in", fileName };
55
+ if (builtInMatches[0]) {
56
+ return { source: "built-in", fileName: builtInMatches[0] };
54
57
  }
55
58
  throw new Error(`Nested flow '${fileName}' was not found.`);
56
59
  }