@sweny-ai/core 0.1.0
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/__tests__/claude.test.d.ts +1 -0
- package/dist/__tests__/claude.test.js +328 -0
- package/dist/__tests__/executor.test.d.ts +1 -0
- package/dist/__tests__/executor.test.js +296 -0
- package/dist/__tests__/integration/datadog.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/datadog.integration.test.js +23 -0
- package/dist/__tests__/integration/e2e-workflow.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/e2e-workflow.integration.test.js +75 -0
- package/dist/__tests__/integration/github.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/github.integration.test.js +37 -0
- package/dist/__tests__/integration/harness.d.ts +24 -0
- package/dist/__tests__/integration/harness.js +34 -0
- package/dist/__tests__/integration/linear.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/linear.integration.test.js +15 -0
- package/dist/__tests__/integration/sentry.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/sentry.integration.test.js +20 -0
- package/dist/__tests__/integration/slack.integration.test.d.ts +1 -0
- package/dist/__tests__/integration/slack.integration.test.js +22 -0
- package/dist/__tests__/schema.test.d.ts +1 -0
- package/dist/__tests__/schema.test.js +239 -0
- package/dist/__tests__/skills-index.test.d.ts +1 -0
- package/dist/__tests__/skills-index.test.js +122 -0
- package/dist/__tests__/skills.test.d.ts +1 -0
- package/dist/__tests__/skills.test.js +296 -0
- package/dist/__tests__/studio.test.d.ts +1 -0
- package/dist/__tests__/studio.test.js +172 -0
- package/dist/__tests__/testing.test.d.ts +1 -0
- package/dist/__tests__/testing.test.js +224 -0
- package/dist/browser.d.ts +17 -0
- package/dist/browser.js +22 -0
- package/dist/claude.d.ts +48 -0
- package/dist/claude.js +293 -0
- package/dist/cli/check.d.ts +11 -0
- package/dist/cli/check.js +237 -0
- package/dist/cli/config-file.d.ts +12 -0
- package/dist/cli/config-file.js +208 -0
- package/dist/cli/config.d.ts +77 -0
- package/dist/cli/config.js +565 -0
- package/dist/cli/main.d.ts +10 -0
- package/dist/cli/main.js +744 -0
- package/dist/cli/output.d.ts +26 -0
- package/dist/cli/output.js +357 -0
- package/dist/cli/renderer.d.ts +33 -0
- package/dist/cli/renderer.js +423 -0
- package/dist/cli/renderer.test.d.ts +1 -0
- package/dist/cli/renderer.test.js +302 -0
- package/dist/cli/setup.d.ts +11 -0
- package/dist/cli/setup.js +310 -0
- package/dist/executor.d.ts +29 -0
- package/dist/executor.js +173 -0
- package/dist/executor.test.d.ts +1 -0
- package/dist/executor.test.js +314 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +36 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +183 -0
- package/dist/mcp.test.d.ts +1 -0
- package/dist/mcp.test.js +334 -0
- package/dist/schema.d.ts +318 -0
- package/dist/schema.js +207 -0
- package/dist/skills/betterstack.d.ts +7 -0
- package/dist/skills/betterstack.js +114 -0
- package/dist/skills/datadog.d.ts +7 -0
- package/dist/skills/datadog.js +107 -0
- package/dist/skills/github.d.ts +8 -0
- package/dist/skills/github.js +155 -0
- package/dist/skills/index.d.ts +68 -0
- package/dist/skills/index.js +134 -0
- package/dist/skills/linear.d.ts +7 -0
- package/dist/skills/linear.js +89 -0
- package/dist/skills/notification.d.ts +11 -0
- package/dist/skills/notification.js +142 -0
- package/dist/skills/sentry.d.ts +7 -0
- package/dist/skills/sentry.js +105 -0
- package/dist/skills/slack.d.ts +8 -0
- package/dist/skills/slack.js +115 -0
- package/dist/studio.d.ts +124 -0
- package/dist/studio.js +174 -0
- package/dist/testing.d.ts +88 -0
- package/dist/testing.js +253 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.js +11 -0
- package/dist/workflow-builder.d.ts +45 -0
- package/dist/workflow-builder.js +120 -0
- package/dist/workflow-builder.test.d.ts +1 -0
- package/dist/workflow-builder.test.js +117 -0
- package/dist/workflows/implement.d.ts +11 -0
- package/dist/workflows/implement.js +83 -0
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/triage.d.ts +18 -0
- package/dist/workflows/triage.js +108 -0
- package/package.json +83 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
3
|
+
/** Strip ANSI escape codes for accurate visible-width calculations. */
|
|
4
|
+
export function stripAnsi(str) {
|
|
5
|
+
return str.replace(/\x1B\[[0-9;]*m/g, "");
|
|
6
|
+
}
|
|
7
|
+
function visLen(str) {
|
|
8
|
+
return stripAnsi(str).length;
|
|
9
|
+
}
|
|
10
|
+
function formatElapsed(ms) {
|
|
11
|
+
const s = Math.round(ms / 1000);
|
|
12
|
+
if (s < 60)
|
|
13
|
+
return `${s}s`;
|
|
14
|
+
const m = Math.floor(s / 60);
|
|
15
|
+
return `${m}m ${s % 60}s`;
|
|
16
|
+
}
|
|
17
|
+
// ── Topological sort ─────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Returns node IDs in topological order starting from the entry node.
|
|
20
|
+
* Uses Kahn's algorithm (BFS from entry) to respect edge ordering.
|
|
21
|
+
*/
|
|
22
|
+
function topologicalOrder(workflow) {
|
|
23
|
+
const nodeIds = Object.keys(workflow.nodes);
|
|
24
|
+
const inDegree = new Map(nodeIds.map((id) => [id, 0]));
|
|
25
|
+
const children = new Map(nodeIds.map((id) => [id, []]));
|
|
26
|
+
for (const edge of workflow.edges) {
|
|
27
|
+
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
|
28
|
+
children.get(edge.from)?.push(edge.to);
|
|
29
|
+
}
|
|
30
|
+
const queue = [workflow.entry];
|
|
31
|
+
const visited = new Set([workflow.entry]);
|
|
32
|
+
const order = [];
|
|
33
|
+
while (queue.length > 0) {
|
|
34
|
+
const current = queue.shift();
|
|
35
|
+
order.push(current);
|
|
36
|
+
for (const child of children.get(current) ?? []) {
|
|
37
|
+
if (!visited.has(child)) {
|
|
38
|
+
visited.add(child);
|
|
39
|
+
queue.push(child);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Append any nodes not reachable from entry (disconnected subgraphs)
|
|
44
|
+
for (const id of nodeIds) {
|
|
45
|
+
if (!visited.has(id)) {
|
|
46
|
+
order.push(id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return order;
|
|
50
|
+
}
|
|
51
|
+
// ── Status icons + colors ────────────────────────────────────────
|
|
52
|
+
function statusIcon(state) {
|
|
53
|
+
switch (state) {
|
|
54
|
+
case "completed":
|
|
55
|
+
return chalk.green("●");
|
|
56
|
+
case "running":
|
|
57
|
+
return chalk.yellow("◉");
|
|
58
|
+
case "pending":
|
|
59
|
+
return chalk.gray("○");
|
|
60
|
+
case "failed":
|
|
61
|
+
return chalk.red("✕");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ── Graph helpers ────────────────────────────────────────────────
|
|
65
|
+
function getChildren(workflow) {
|
|
66
|
+
const children = new Map(Object.keys(workflow.nodes).map((id) => [id, []]));
|
|
67
|
+
for (const edge of workflow.edges) {
|
|
68
|
+
children.get(edge.from)?.push(edge.to);
|
|
69
|
+
}
|
|
70
|
+
return children;
|
|
71
|
+
}
|
|
72
|
+
// ── Box rendering ────────────────────────────────────────────────
|
|
73
|
+
function getNodeDetail(nodeRenderState) {
|
|
74
|
+
const { state, toolCallCount, startedAt, finishedAt } = nodeRenderState;
|
|
75
|
+
if (state === "running" && startedAt != null) {
|
|
76
|
+
const elapsed = formatElapsed(Date.now() - startedAt);
|
|
77
|
+
return chalk.dim(` ${elapsed}`);
|
|
78
|
+
}
|
|
79
|
+
else if (state === "completed" && toolCallCount > 0) {
|
|
80
|
+
return chalk.dim(` ${toolCallCount} call${toolCallCount === 1 ? "" : "s"}`);
|
|
81
|
+
}
|
|
82
|
+
else if (state === "failed" && startedAt != null && finishedAt != null) {
|
|
83
|
+
const elapsed = formatElapsed(finishedAt - startedAt);
|
|
84
|
+
return chalk.dim(` ${elapsed}`);
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Word-wrap a node name into lines that fit within maxChars.
|
|
90
|
+
* Splits on spaces. If a single word exceeds maxChars, it's placed on its own line.
|
|
91
|
+
*/
|
|
92
|
+
function wrapName(name, maxChars) {
|
|
93
|
+
const words = name.split(" ");
|
|
94
|
+
const lines = [];
|
|
95
|
+
let current = "";
|
|
96
|
+
for (const word of words) {
|
|
97
|
+
if (current.length === 0) {
|
|
98
|
+
current = word;
|
|
99
|
+
}
|
|
100
|
+
else if (current.length + 1 + word.length <= maxChars) {
|
|
101
|
+
current += " " + word;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
lines.push(current);
|
|
105
|
+
current = word;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current.length > 0)
|
|
109
|
+
lines.push(current);
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Render a single node as a box.
|
|
114
|
+
* @param innerWidth - the uniform inner width for all boxes
|
|
115
|
+
* @param indent - number of leading spaces
|
|
116
|
+
* @param topConnector - "plain" (┌───┐), "arrow" (┌───▼───┐)
|
|
117
|
+
* @param bottomConnector - "plain" (└───┘), "tee" (└───┬───┘)
|
|
118
|
+
* @param connectorCol - absolute column for ▼/┬ (default: centered in the box)
|
|
119
|
+
*/
|
|
120
|
+
function renderNodeBox(nodeName, nodeRenderState, innerWidth, indent, topConnector, bottomConnector, connectorCol) {
|
|
121
|
+
const icon = statusIcon(nodeRenderState.state);
|
|
122
|
+
const detail = getNodeDetail(nodeRenderState);
|
|
123
|
+
const pad = " ".repeat(indent);
|
|
124
|
+
// Position of connector character within the inner width (offset from ┌/└)
|
|
125
|
+
const mid = connectorCol != null ? connectorCol - indent - 1 : Math.floor(innerWidth / 2);
|
|
126
|
+
// Top border
|
|
127
|
+
let top;
|
|
128
|
+
if (topConnector === "arrow") {
|
|
129
|
+
const leftDashes = "─".repeat(mid);
|
|
130
|
+
const rightDashes = "─".repeat(innerWidth - mid - 1);
|
|
131
|
+
top = `${pad}┌${leftDashes}▼${rightDashes}┐`;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
top = `${pad}┌${"─".repeat(innerWidth)}┐`;
|
|
135
|
+
}
|
|
136
|
+
// Bottom border
|
|
137
|
+
let bottom;
|
|
138
|
+
if (bottomConnector === "tee") {
|
|
139
|
+
const leftDashes = "─".repeat(mid);
|
|
140
|
+
const rightDashes = "─".repeat(innerWidth - mid - 1);
|
|
141
|
+
bottom = `${pad}└${leftDashes}┬${rightDashes}┘`;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
bottom = `${pad}└${"─".repeat(innerWidth)}┘`;
|
|
145
|
+
}
|
|
146
|
+
// Content lines — icon on first line, wrapped name
|
|
147
|
+
// Available space for text: innerWidth - 4 (1 space + icon + 1 space + text + padding + 1 space)
|
|
148
|
+
// "│ ○ Name...padding │" → icon(1) + spaces(2) + name + pad
|
|
149
|
+
const iconVis = stripAnsi(icon); // 1 char
|
|
150
|
+
const detailVis = stripAnsi(detail);
|
|
151
|
+
// Max text width for name: innerWidth - 2 (margins) - 2 (icon + space)
|
|
152
|
+
const maxNameWidth = innerWidth - 4; // "│ X name_here... │" → 1+1+1+1 = 4 overhead
|
|
153
|
+
const nameLines = wrapName(nodeName, maxNameWidth);
|
|
154
|
+
const contentLines = [];
|
|
155
|
+
for (let li = 0; li < nameLines.length; li++) {
|
|
156
|
+
const isFirst = li === 0;
|
|
157
|
+
let lineText;
|
|
158
|
+
let lineVisLen;
|
|
159
|
+
if (isFirst) {
|
|
160
|
+
// First line includes icon and possibly detail
|
|
161
|
+
const namePart = nameLines[li];
|
|
162
|
+
// Check if detail fits on first line
|
|
163
|
+
if (nameLines.length === 1 && namePart.length + detailVis.length <= maxNameWidth) {
|
|
164
|
+
lineText = `${icon} ${namePart}${detail}`;
|
|
165
|
+
lineVisLen = iconVis.length + 1 + namePart.length + detailVis.length;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
lineText = `${icon} ${namePart}`;
|
|
169
|
+
lineVisLen = iconVis.length + 1 + namePart.length;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Continuation lines indented to align with name start (after "X ")
|
|
174
|
+
lineText = ` ${nameLines[li]}`;
|
|
175
|
+
lineVisLen = 2 + nameLines[li].length;
|
|
176
|
+
}
|
|
177
|
+
const rightPad = Math.max(0, innerWidth - 2 - lineVisLen);
|
|
178
|
+
contentLines.push(`${pad}│ ${lineText}${" ".repeat(rightPad)} │`);
|
|
179
|
+
}
|
|
180
|
+
// If detail didn't fit on the first line of a multi-line name, add it to last line
|
|
181
|
+
// Actually, for simplicity, detail goes on the first line only when name is single-line
|
|
182
|
+
return [top, ...contentLines, bottom];
|
|
183
|
+
}
|
|
184
|
+
// ── Legend ───────────────────────────────────────────────────────
|
|
185
|
+
function renderLegend() {
|
|
186
|
+
const items = [
|
|
187
|
+
`${chalk.green("●")} completed`,
|
|
188
|
+
`${chalk.yellow("◉")} running`,
|
|
189
|
+
`${chalk.gray("○")} pending`,
|
|
190
|
+
`${chalk.red("✕")} failed`,
|
|
191
|
+
];
|
|
192
|
+
return `\n ${chalk.dim(items.join(" "))}`;
|
|
193
|
+
}
|
|
194
|
+
export class DagRenderer {
|
|
195
|
+
workflow;
|
|
196
|
+
options;
|
|
197
|
+
nodeStates;
|
|
198
|
+
topoOrder;
|
|
199
|
+
/** Number of lines written in the last render pass (for cursor repositioning). */
|
|
200
|
+
lastLineCount = 0;
|
|
201
|
+
constructor(workflow, options = {}) {
|
|
202
|
+
this.workflow = workflow;
|
|
203
|
+
this.options = {
|
|
204
|
+
animate: options.animate ?? false,
|
|
205
|
+
stream: options.stream ??
|
|
206
|
+
(typeof process !== "undefined" ? process.stderr : undefined),
|
|
207
|
+
};
|
|
208
|
+
this.nodeStates = new Map(Object.keys(workflow.nodes).map((id) => [id, { state: "pending", toolCallCount: 0 }]));
|
|
209
|
+
this.topoOrder = topologicalOrder(workflow);
|
|
210
|
+
}
|
|
211
|
+
// ── Public API ─────────────────────────────────────────────────
|
|
212
|
+
/** Update renderer state from an execution event. Re-renders if animate=true. */
|
|
213
|
+
update(event) {
|
|
214
|
+
switch (event.type) {
|
|
215
|
+
case "node:enter": {
|
|
216
|
+
const s = this.getOrCreate(event.node);
|
|
217
|
+
s.state = "running";
|
|
218
|
+
s.startedAt = Date.now();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "tool:call": {
|
|
222
|
+
const s = this.getOrCreate(event.node);
|
|
223
|
+
s.toolCallCount++;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "node:exit": {
|
|
227
|
+
const s = this.getOrCreate(event.node);
|
|
228
|
+
s.state = event.result.status === "failed" ? "failed" : "completed";
|
|
229
|
+
s.finishedAt = Date.now();
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
// These events don't affect per-node rendering state
|
|
233
|
+
case "workflow:start":
|
|
234
|
+
case "workflow:end":
|
|
235
|
+
case "tool:result":
|
|
236
|
+
case "route":
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (this.options.animate) {
|
|
240
|
+
this.render();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Returns the current state of a node. Returns "pending" for unknown nodes. */
|
|
244
|
+
getNodeState(nodeId) {
|
|
245
|
+
return this.nodeStates.get(nodeId)?.state ?? "pending";
|
|
246
|
+
}
|
|
247
|
+
/** Returns the number of tool calls for a node. Returns 0 for unknown nodes. */
|
|
248
|
+
getToolCallCount(nodeId) {
|
|
249
|
+
return this.nodeStates.get(nodeId)?.toolCallCount ?? 0;
|
|
250
|
+
}
|
|
251
|
+
/** Renders the DAG to a string (no side effects). */
|
|
252
|
+
renderToString() {
|
|
253
|
+
const children = getChildren(this.workflow);
|
|
254
|
+
// Pre-compute uniform inner width from the max node name across ALL nodes
|
|
255
|
+
const MIN_INNER = 20;
|
|
256
|
+
let maxNameLen = 0;
|
|
257
|
+
for (const nodeId of this.topoOrder) {
|
|
258
|
+
const node = this.workflow.nodes[nodeId];
|
|
259
|
+
if (node) {
|
|
260
|
+
maxNameLen = Math.max(maxNameLen, node.name.length);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// innerWidth = "│ X " + name + " │" → name + 4 + some padding
|
|
264
|
+
const boxInnerWidth = Math.max(maxNameLen + 4, MIN_INNER);
|
|
265
|
+
const indent = 2; // global left indent for main-column boxes
|
|
266
|
+
const midCol = indent + 1 + Math.floor(boxInnerWidth / 2); // center column for connectors (indent + border + half inner)
|
|
267
|
+
const lines = [];
|
|
268
|
+
lines.push(` ${chalk.bold(this.workflow.name)}`);
|
|
269
|
+
lines.push("");
|
|
270
|
+
// Build a set of nodes that have been rendered (to handle branches)
|
|
271
|
+
const rendered = new Set();
|
|
272
|
+
// Track the column where the incoming connector arrives from (for merge nodes after branches)
|
|
273
|
+
// When set, the next node's ▼ is placed at this column instead of centered
|
|
274
|
+
let incomingConnectorCol;
|
|
275
|
+
for (let i = 0; i < this.topoOrder.length; i++) {
|
|
276
|
+
const nodeId = this.topoOrder[i];
|
|
277
|
+
if (rendered.has(nodeId))
|
|
278
|
+
continue;
|
|
279
|
+
const node = this.workflow.nodes[nodeId];
|
|
280
|
+
if (!node)
|
|
281
|
+
continue;
|
|
282
|
+
const state = this.nodeStates.get(nodeId) ?? { state: "pending", toolCallCount: 0 };
|
|
283
|
+
const kids = children.get(nodeId) ?? [];
|
|
284
|
+
// Check if this node has exactly 2 children → render fork
|
|
285
|
+
if (kids.length === 2) {
|
|
286
|
+
// Render this node with a tee bottom
|
|
287
|
+
const isFirstRendered = rendered.size === 0;
|
|
288
|
+
const boxLines = renderNodeBox(node.name, state, boxInnerWidth, indent, isFirstRendered ? "plain" : "arrow", "tee", incomingConnectorCol);
|
|
289
|
+
lines.push(...boxLines);
|
|
290
|
+
rendered.add(nodeId);
|
|
291
|
+
incomingConnectorCol = undefined;
|
|
292
|
+
// Render fork connector and side-by-side children
|
|
293
|
+
const leftId = kids[0];
|
|
294
|
+
const rightId = kids[1];
|
|
295
|
+
const leftNode = this.workflow.nodes[leftId];
|
|
296
|
+
const rightNode = this.workflow.nodes[rightId];
|
|
297
|
+
if (!leftNode || !rightNode)
|
|
298
|
+
continue;
|
|
299
|
+
const leftState = this.nodeStates.get(leftId) ?? { state: "pending", toolCallCount: 0 };
|
|
300
|
+
const rightState = this.nodeStates.get(rightId) ?? { state: "pending", toolCallCount: 0 };
|
|
301
|
+
// Determine child box widths — each child gets its own width based on its name
|
|
302
|
+
const leftKids = children.get(leftId) ?? [];
|
|
303
|
+
const rightKids = children.get(rightId) ?? [];
|
|
304
|
+
// For side-by-side, compute widths that fit
|
|
305
|
+
const leftInner = Math.max(leftNode.name.length + 4, 14);
|
|
306
|
+
const rightInner = Math.max(rightNode.name.length + 4, 14);
|
|
307
|
+
// Gap between the two child boxes: 1 space
|
|
308
|
+
const gap = 1;
|
|
309
|
+
const leftBoxOuter = leftInner + 2; // +2 for ┌/│ and ┐/│
|
|
310
|
+
const rightBoxOuter = rightInner + 2;
|
|
311
|
+
// Center the pair around midCol
|
|
312
|
+
const totalWidth = leftBoxOuter + gap + rightBoxOuter;
|
|
313
|
+
const pairStart = Math.max(0, midCol - Math.floor(totalWidth / 2));
|
|
314
|
+
const leftIndent = pairStart;
|
|
315
|
+
const rightIndent = pairStart + leftBoxOuter + gap;
|
|
316
|
+
// Left box center and right box center (absolute columns)
|
|
317
|
+
const leftCenter = leftIndent + 1 + Math.floor(leftInner / 2);
|
|
318
|
+
const rightCenter = rightIndent + 1 + Math.floor(rightInner / 2);
|
|
319
|
+
// Vertical connector from parent ┬
|
|
320
|
+
lines.push(" ".repeat(midCol) + "│");
|
|
321
|
+
// Fork line: ┌───┴───┐ with horizontal line from leftCenter to rightCenter
|
|
322
|
+
const forkLineChars = new Array(Math.max(rightCenter, midCol) + 1).fill(" ");
|
|
323
|
+
for (let c = leftCenter; c <= rightCenter; c++) {
|
|
324
|
+
forkLineChars[c] = "─";
|
|
325
|
+
}
|
|
326
|
+
forkLineChars[leftCenter] = "┌";
|
|
327
|
+
forkLineChars[rightCenter] = "┐";
|
|
328
|
+
forkLineChars[midCol] = "┴";
|
|
329
|
+
lines.push(forkLineChars.join(""));
|
|
330
|
+
// Vertical connectors from fork to child boxes
|
|
331
|
+
const vertLineChars = new Array(Math.max(rightCenter, midCol) + 1).fill(" ");
|
|
332
|
+
vertLineChars[leftCenter] = "│";
|
|
333
|
+
vertLineChars[rightCenter] = "│";
|
|
334
|
+
lines.push(vertLineChars.join(""));
|
|
335
|
+
// Render left and right child boxes side by side
|
|
336
|
+
const leftBox = renderNodeBox(leftNode.name, leftState, leftInner, leftIndent, "arrow", leftKids.length > 0 ? "tee" : "plain", leftCenter);
|
|
337
|
+
const rightBox = renderNodeBox(rightNode.name, rightState, rightInner, rightIndent, "arrow", rightKids.length > 0 ? "tee" : "plain", rightCenter);
|
|
338
|
+
// Merge left and right box lines side-by-side
|
|
339
|
+
const maxBoxLines = Math.max(leftBox.length, rightBox.length);
|
|
340
|
+
for (let li = 0; li < maxBoxLines; li++) {
|
|
341
|
+
const leftLine = li < leftBox.length ? leftBox[li] : "";
|
|
342
|
+
const rightLine = li < rightBox.length ? rightBox[li] : "";
|
|
343
|
+
const leftVisLen = visLen(leftLine);
|
|
344
|
+
if (rightLine.length > 0) {
|
|
345
|
+
const rightContent = rightLine.trimStart();
|
|
346
|
+
const rightStartCol = visLen(rightLine) - visLen(rightLine.trimStart());
|
|
347
|
+
const neededPad = Math.max(0, rightStartCol - leftVisLen);
|
|
348
|
+
lines.push(leftLine + " ".repeat(neededPad) + rightContent);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
lines.push(leftLine);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
rendered.add(leftId);
|
|
355
|
+
rendered.add(rightId);
|
|
356
|
+
// Find the continuation/merge node
|
|
357
|
+
const leftNextIds = leftKids;
|
|
358
|
+
const rightNextIds = rightKids;
|
|
359
|
+
// Find merge point: a node reachable from either branch
|
|
360
|
+
let mergeNodeId;
|
|
361
|
+
const leftNextSet = new Set(leftNextIds);
|
|
362
|
+
for (const rn of rightNextIds) {
|
|
363
|
+
if (leftNextSet.has(rn)) {
|
|
364
|
+
mergeNodeId = rn;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// If only one branch has a continuation, use that
|
|
369
|
+
if (!mergeNodeId && leftNextIds.length > 0) {
|
|
370
|
+
mergeNodeId = leftNextIds[0];
|
|
371
|
+
}
|
|
372
|
+
// Render connector from left child down to merge node
|
|
373
|
+
if (mergeNodeId && leftKids.length > 0) {
|
|
374
|
+
// The left child's ┬ is at leftCenter
|
|
375
|
+
lines.push(" ".repeat(leftCenter) + "│");
|
|
376
|
+
// Tell the merge node to place its ▼ at leftCenter
|
|
377
|
+
incomingConnectorCol = leftCenter;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// Regular sequential node
|
|
382
|
+
const isFirstRendered = rendered.size === 0;
|
|
383
|
+
const hasDownstream = kids.length > 0;
|
|
384
|
+
const boxLines = renderNodeBox(node.name, state, boxInnerWidth, indent, isFirstRendered ? "plain" : "arrow", hasDownstream ? "tee" : "plain", incomingConnectorCol);
|
|
385
|
+
lines.push(...boxLines);
|
|
386
|
+
rendered.add(nodeId);
|
|
387
|
+
incomingConnectorCol = undefined;
|
|
388
|
+
// Add vertical connector if there's a next node
|
|
389
|
+
if (hasDownstream) {
|
|
390
|
+
lines.push(" ".repeat(midCol) + "│");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
lines.push(renderLegend());
|
|
395
|
+
return lines.join("\n");
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Writes the current DAG render to the stream.
|
|
399
|
+
* When animate=true, uses cursor manipulation to update in place.
|
|
400
|
+
*/
|
|
401
|
+
render() {
|
|
402
|
+
const output = this.renderToString();
|
|
403
|
+
const lineCount = output.split("\n").length;
|
|
404
|
+
const stream = this.options.stream;
|
|
405
|
+
if (!stream)
|
|
406
|
+
return;
|
|
407
|
+
if (this.options.animate && this.lastLineCount > 0) {
|
|
408
|
+
// Move cursor up to overwrite previous render
|
|
409
|
+
stream.write(`\x1B[${this.lastLineCount}A`);
|
|
410
|
+
}
|
|
411
|
+
stream.write(output + "\n");
|
|
412
|
+
this.lastLineCount = lineCount + 1; // +1 for the trailing newline
|
|
413
|
+
}
|
|
414
|
+
// ── Private helpers ────────────────────────────────────────────
|
|
415
|
+
getOrCreate(nodeId) {
|
|
416
|
+
let s = this.nodeStates.get(nodeId);
|
|
417
|
+
if (!s) {
|
|
418
|
+
s = { state: "pending", toolCallCount: 0 };
|
|
419
|
+
this.nodeStates.set(nodeId, s);
|
|
420
|
+
}
|
|
421
|
+
return s;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|