pi-agent-flow 0.1.0-alpha.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.
package/render.ts ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * TUI rendering for flow-state tool calls and results.
3
+ *
4
+ * Option B: collapsed view shows structured report (Summary/Done/Not Done/Next Steps).
5
+ * Expanded view adds raw tool call traces.
6
+ */
7
+
8
+ import * as os from "node:os";
9
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
10
+ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
11
+ import { getFlowSummaryText } from "./runner-events.js";
12
+ import {
13
+ type DisplayItem,
14
+ type SingleResult,
15
+ type FlowDetails,
16
+ type UsageStats,
17
+ aggregateFlowUsage,
18
+ getFlowDisplayItems,
19
+ getFlowOutput,
20
+ isFlowError,
21
+ isFlowSuccess,
22
+ } from "./types.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Formatting helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function formatTokens(count: number): string {
29
+ if (count < 1000) return count.toString();
30
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
31
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
32
+ return `${(count / 1000000).toFixed(1)}M`;
33
+ }
34
+
35
+ function formatFlowUsage(usage: Partial<UsageStats>, model?: string): string {
36
+ const parts: string[] = [];
37
+ if (usage.toolCalls && usage.toolCalls > 0) parts.push(`${usage.toolCalls} calls`);
38
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
39
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
40
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
41
+ if (usage.cacheRead) parts.push(`CR:${formatTokens(usage.cacheRead)}`);
42
+ if (usage.cacheWrite) parts.push(`CW:${formatTokens(usage.cacheWrite)}`);
43
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
44
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
45
+ if (model) parts.push(model);
46
+ return parts.join(" ");
47
+ }
48
+
49
+ function shortenPath(p: string): string {
50
+ const home = os.homedir();
51
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
52
+ }
53
+
54
+ type ThemeFg = (color: string, text: string) => string;
55
+
56
+ function formatFlowToolCall(toolName: string, args: Record<string, unknown>, fg: ThemeFg): string {
57
+ const pathArg = (args.file_path || args.path || "...") as string;
58
+
59
+ switch (toolName) {
60
+ case "bash": {
61
+ const cmd = (args.command as string) || "...";
62
+ return fg("muted", "$ ") + fg("toolOutput", cmd);
63
+ }
64
+ case "read": {
65
+ let text = fg("accent", shortenPath(pathArg));
66
+ const offset = args.offset as number | undefined;
67
+ const limit = args.limit as number | undefined;
68
+ if (offset !== undefined || limit !== undefined) {
69
+ const start = offset ?? 1;
70
+ const end = limit !== undefined ? start + limit - 1 : "";
71
+ text += fg("warning", `:${start}${end ? `-${end}` : ""}`);
72
+ }
73
+ return fg("muted", "read ") + text;
74
+ }
75
+ case "write": {
76
+ const lines = ((args.content || "") as string).split("\n").length;
77
+ let text = fg("muted", "write ") + fg("accent", shortenPath(pathArg));
78
+ if (lines > 1) text += fg("dim", ` (${lines} lines)`);
79
+ return text;
80
+ }
81
+ case "edit":
82
+ return fg("muted", "edit ") + fg("accent", shortenPath(pathArg));
83
+ case "ls":
84
+ return fg("muted", "ls ") + fg("accent", shortenPath((args.path || ".") as string));
85
+ case "find":
86
+ return fg("muted", "find ") + fg("accent", (args.pattern || "*") as string) + fg("dim", ` in ${shortenPath((args.path || ".") as string)}`);
87
+ case "grep":
88
+ return fg("muted", "grep ") + fg("accent", `/${(args.pattern || "") as string}/`) + fg("dim", ` in ${shortenPath((args.path || ".") as string)}`);
89
+ default:
90
+ return fg("accent", toolName) + fg("dim", ` ${JSON.stringify(args)}`);
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Shared rendering building blocks
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function splitOutputLines(text: string): string[] {
99
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
100
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
101
+ return lines;
102
+ }
103
+
104
+ function renderToolTraces(
105
+ items: DisplayItem[],
106
+ theme: { fg: ThemeFg },
107
+ ): string {
108
+ const lines: string[] = [];
109
+ for (const item of items) {
110
+ if (item.type === "toolCall") {
111
+ lines.push(theme.fg("muted", "→ ") + formatFlowToolCall(item.name, item.args, theme.fg.bind(theme)));
112
+ }
113
+ }
114
+ return lines.join("\n");
115
+ }
116
+
117
+ function renderFlowReport(
118
+ output: string,
119
+ theme: { fg: ThemeFg },
120
+ ): string {
121
+ const lines = splitOutputLines(output);
122
+ return lines.map((line) => theme.fg("toolOutput", line)).join("\n");
123
+ }
124
+
125
+ function flowStatusIcon(r: SingleResult, theme: { fg: ThemeFg }): string {
126
+ if (r.exitCode === -1) return theme.fg("warning", "⏳");
127
+ return isFlowError(r) ? theme.fg("error", "✗") : theme.fg("success", "✓");
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // renderFlowCall — shown while the flow is being invoked
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function truncateText(text: string): string {
135
+ const words = text.split(/\s+/);
136
+ if (words.length <= 12) return text;
137
+ return `${words.slice(0, 3).join(" ")} ... ${words.slice(-8).join(" ")}`;
138
+ }
139
+
140
+ export function renderFlowCall(args: Record<string, any>, theme: { fg: ThemeFg; bold: (s: string) => string }): Text {
141
+ const flows = args.flow as Array<{ type: string; intent: string }> | undefined;
142
+
143
+ if (flows && flows.length > 0) {
144
+ if (flows.length === 1) {
145
+ const f = flows[0];
146
+ const text =
147
+ theme.fg("toolTitle", "routing to ") +
148
+ theme.fg("accent", `flow [${f.type}]`) +
149
+ theme.fg("dim", ` — ${truncateText(f.intent)}`);
150
+ return new Text(text, 0, 0);
151
+ }
152
+
153
+ let text = theme.fg("toolTitle", "routing to:");
154
+ for (const f of flows) {
155
+ text +=
156
+ `\n ${theme.fg("muted", "•")} ` +
157
+ theme.fg("accent", `flow [${f.type}]`) +
158
+ theme.fg("dim", ` — ${truncateText(f.intent)}`);
159
+ }
160
+ return new Text(text, 0, 0);
161
+ }
162
+
163
+ return new Text(theme.fg("muted", "(empty flow call)"), 0, 0);
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // renderFlowResult — shown after the flow completes
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export function renderFlowResult(
171
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
172
+ expanded: boolean,
173
+ theme: { fg: ThemeFg; bold: (s: string) => string },
174
+ ): Container | Text {
175
+ const details = result.details as FlowDetails | undefined;
176
+ if (!details || details.results.length === 0) {
177
+ const first = result.content[0];
178
+ return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
179
+ }
180
+
181
+ if (details.results.length === 1) {
182
+ return renderSingleFlowResult(details.results[0], expanded, theme);
183
+ }
184
+
185
+ return renderMultiFlowResult(details, expanded, theme);
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Single flow result
190
+ // ---------------------------------------------------------------------------
191
+
192
+ function renderSingleFlowResult(
193
+ r: SingleResult,
194
+ expanded: boolean,
195
+ theme: { fg: ThemeFg; bold: (s: string) => string },
196
+ ): Container | Text {
197
+ const error = isFlowError(r);
198
+ const icon = flowStatusIcon(r, theme);
199
+ const displayItems = getFlowDisplayItems(r.messages);
200
+ const flowOutput = getFlowOutput(r.messages);
201
+
202
+ if (expanded) {
203
+ return renderFlowExpanded(r, icon, error, displayItems, flowOutput, theme);
204
+ }
205
+ return renderFlowCollapsed(r, icon, error, flowOutput, theme);
206
+ }
207
+
208
+ function renderFlowExpanded(
209
+ r: SingleResult,
210
+ icon: string,
211
+ error: boolean,
212
+ displayItems: DisplayItem[],
213
+ flowOutput: string,
214
+ theme: { fg: ThemeFg; bold: (s: string) => string },
215
+ ): Container {
216
+ const mdTheme = getMarkdownTheme();
217
+ const container = new Container();
218
+
219
+ // Header
220
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(`[${r.type}]`))}${theme.fg("muted", ` (${r.agentSource})`)}`;
221
+ if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
222
+ container.addChild(new Text(header, 0, 0));
223
+ if (error && r.errorMessage) {
224
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
225
+ }
226
+
227
+ // Intent
228
+ container.addChild(new Spacer(1));
229
+ container.addChild(new Text(theme.fg("muted", "─── Intent ───"), 0, 0));
230
+ container.addChild(new Text(theme.fg("dim", r.intent), 0, 0));
231
+
232
+ // Flow report (structured output)
233
+ container.addChild(new Spacer(1));
234
+ container.addChild(new Text(theme.fg("muted", "─── Report ───"), 0, 0));
235
+ if (flowOutput) {
236
+ container.addChild(new Markdown(flowOutput.trim(), 0, 0, mdTheme));
237
+ } else {
238
+ const summary = getFlowSummaryText(r);
239
+ container.addChild(new Text(theme.fg("muted", summary), 0, 0));
240
+ }
241
+
242
+ // Tool traces (expanded only)
243
+ const toolTraces = renderToolTraces(displayItems, theme);
244
+ if (toolTraces) {
245
+ container.addChild(new Spacer(1));
246
+ container.addChild(new Text(theme.fg("muted", "─── Tool Calls ───"), 0, 0));
247
+ container.addChild(new Text(toolTraces, 0, 0));
248
+ }
249
+
250
+ // Usage
251
+ const usageStr = formatFlowUsage(r.usage, r.model);
252
+ if (usageStr) {
253
+ container.addChild(new Spacer(1));
254
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
255
+ }
256
+
257
+ return container;
258
+ }
259
+
260
+ function renderFlowCollapsed(
261
+ r: SingleResult,
262
+ icon: string,
263
+ error: boolean,
264
+ flowOutput: string,
265
+ theme: { fg: ThemeFg; bold: (s: string) => string },
266
+ ): Text {
267
+ const usageStr = formatFlowUsage(r.usage, r.model);
268
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(`[${r.type}]`))}${theme.fg("muted", ` (${r.agentSource})`)}`;
269
+ if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
270
+ if (error && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
271
+
272
+ if (error && r.errorMessage) {
273
+ text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
274
+ } else if (flowOutput) {
275
+ text += `\n${renderFlowReport(truncateText(flowOutput), theme)}`;
276
+ } else {
277
+ text += `\n${theme.fg(error ? "error" : "muted", getFlowSummaryText(r))}`;
278
+ }
279
+
280
+ return new Text(text, 0, 0);
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Multi-flow result
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function renderMultiFlowResult(
288
+ details: FlowDetails,
289
+ expanded: boolean,
290
+ theme: { fg: ThemeFg; bold: (s: string) => string },
291
+ ): Container | Text {
292
+ const results = details.results;
293
+ const successCount = results.filter((r) => isFlowSuccess(r)).length;
294
+ const failCount = results.filter((r) => isFlowError(r)).length;
295
+ const icon = failCount > 0 ? theme.fg("warning", "◐") : theme.fg("success", "✓");
296
+
297
+ if (expanded) {
298
+ return renderMultiFlowExpanded(results, successCount, icon, theme);
299
+ }
300
+ return renderMultiFlowCollapsed(results, successCount, icon, theme);
301
+ }
302
+
303
+ function renderMultiFlowExpanded(
304
+ results: SingleResult[],
305
+ successCount: number,
306
+ icon: string,
307
+ theme: { fg: ThemeFg; bold: (s: string) => string },
308
+ ): Container {
309
+ const mdTheme = getMarkdownTheme();
310
+ const container = new Container();
311
+
312
+ container.addChild(new Text(
313
+ `${icon} ${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", `${successCount}/${results.length} flows`)}`,
314
+ 0, 0,
315
+ ));
316
+
317
+ for (const r of results) {
318
+ const rIcon = flowStatusIcon(r, theme);
319
+ const displayItems = getFlowDisplayItems(r.messages);
320
+ const flowOutput = getFlowOutput(r.messages);
321
+
322
+ container.addChild(new Spacer(1));
323
+ container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", `[${r.type}]`)} ${rIcon}`, 0, 0));
324
+ container.addChild(new Text(theme.fg("muted", "Intent: ") + theme.fg("dim", r.intent), 0, 0));
325
+
326
+ if (flowOutput) {
327
+ container.addChild(new Spacer(1));
328
+ container.addChild(new Markdown(flowOutput.trim(), 0, 0, mdTheme));
329
+ }
330
+
331
+ // Tool traces in expanded view
332
+ const toolTraces = renderToolTraces(displayItems, theme);
333
+ if (toolTraces) {
334
+ container.addChild(new Spacer(1));
335
+ container.addChild(new Text(theme.fg("muted", "─── Tool Calls ───"), 0, 0));
336
+ container.addChild(new Text(toolTraces, 0, 0));
337
+ }
338
+
339
+ const taskUsage = formatFlowUsage(r.usage, r.model);
340
+ if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
341
+ }
342
+
343
+ const totalUsage = formatFlowUsage(aggregateFlowUsage(results));
344
+ if (totalUsage) {
345
+ container.addChild(new Spacer(1));
346
+ container.addChild(new Text(theme.fg("dim", `Total: ${totalUsage}`), 0, 0));
347
+ }
348
+
349
+ return container;
350
+ }
351
+
352
+ function renderMultiFlowCollapsed(
353
+ results: SingleResult[],
354
+ successCount: number,
355
+ icon: string,
356
+ theme: { fg: ThemeFg; bold: (s: string) => string },
357
+ ): Text {
358
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", `${successCount}/${results.length} flows`)}`;
359
+
360
+ for (const r of results) {
361
+ const rIcon = flowStatusIcon(r, theme);
362
+ const flowOutput = getFlowOutput(r.messages);
363
+ const usageStr = formatFlowUsage(r.usage, r.model);
364
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", `[${r.type}]`)} ${rIcon}`;
365
+ if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
366
+ if (flowOutput) {
367
+ text += `\n${renderFlowReport(truncateText(flowOutput), theme)}`;
368
+ } else {
369
+ text += `\n${theme.fg("muted", getFlowSummaryText(r))}`;
370
+ }
371
+ }
372
+
373
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand tool traces)")}`;
374
+
375
+ return new Text(text, 0, 0);
376
+ }
package/runner-cli.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Helpers for inheriting selected parent CLI flags in child flow processes.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+
9
+ function looksLikeExplicitRelativePath(value) {
10
+ return (
11
+ value.startsWith("./") ||
12
+ value.startsWith("../") ||
13
+ value.startsWith(".\\") ||
14
+ value.startsWith("..\\")
15
+ );
16
+ }
17
+
18
+ function resolvePathArg(value, options = {}) {
19
+ const { allowPackageSource = false, alwaysResolveRelative = false } = options;
20
+ if (!value) return value;
21
+ if (allowPackageSource && (value.startsWith("npm:") || value.startsWith("git:"))) return value;
22
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
23
+ if (path.isAbsolute(value)) return value;
24
+
25
+ const resolved = path.resolve(process.cwd(), value);
26
+ if (
27
+ alwaysResolveRelative ||
28
+ looksLikeExplicitRelativePath(value) ||
29
+ path.extname(value) !== "" ||
30
+ fs.existsSync(resolved)
31
+ ) {
32
+ return resolved;
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Parse process.argv into groups used for child flow invocations.
39
+ *
40
+ * - extensionArgs: forwarded with path resolution
41
+ * - alwaysProxy: forwarded verbatim to every child
42
+ * - fallbackModel/thinking/tools: used only when the flow file does not set them
43
+ */
44
+ export function parseFlowCliArgs(argv) {
45
+ const extensionArgs = [];
46
+ const alwaysProxy = [];
47
+ let fallbackModel;
48
+ let fallbackThinking;
49
+ let fallbackTools;
50
+ let fallbackNoTools = false;
51
+
52
+ let i = 2; // skip executable + script name
53
+ while (i < argv.length) {
54
+ const raw = argv[i];
55
+ if (!raw.startsWith("-")) {
56
+ i++;
57
+ continue;
58
+ }
59
+
60
+ const eqIdx = raw.indexOf("=");
61
+ const flagName = eqIdx !== -1 ? raw.slice(0, eqIdx) : raw;
62
+ const inlineValue = eqIdx !== -1 ? raw.slice(eqIdx + 1) : undefined;
63
+
64
+ const nextToken = argv[i + 1];
65
+ const nextIsValue = nextToken !== undefined && !nextToken.startsWith("-");
66
+
67
+ const getValue = () => {
68
+ if (inlineValue !== undefined) return [inlineValue, 1];
69
+ if (nextIsValue) return [nextToken, 2];
70
+ return [undefined, 1];
71
+ };
72
+
73
+ if (
74
+ [
75
+ "--mode",
76
+ "--session",
77
+ "--append-system-prompt",
78
+ "--export",
79
+ "--flow-max-depth",
80
+ ].includes(flagName)
81
+ ) {
82
+ const [, skip] = getValue();
83
+ i += skip;
84
+ continue;
85
+ }
86
+
87
+ if (["--flow-prevent-cycles", "--list-models"].includes(flagName)) {
88
+ const [, skip] = getValue();
89
+ i += skip;
90
+ continue;
91
+ }
92
+
93
+ if (
94
+ [
95
+ "--print",
96
+ "-p",
97
+ "--no-session",
98
+ "--continue",
99
+ "-c",
100
+ "--resume",
101
+ "-r",
102
+ "--offline",
103
+ "--help",
104
+ "-h",
105
+ "--version",
106
+ "-v",
107
+ "--no-flow-prevent-cycles",
108
+ ].includes(flagName)
109
+ ) {
110
+ i++;
111
+ continue;
112
+ }
113
+
114
+ if (flagName === "--no-extensions" || flagName === "-ne") {
115
+ extensionArgs.push(flagName);
116
+ i++;
117
+ continue;
118
+ }
119
+
120
+ if (flagName === "--extension" || flagName === "-e") {
121
+ const [value, skip] = getValue();
122
+ if (value !== undefined) {
123
+ extensionArgs.push(flagName, resolvePathArg(value, { allowPackageSource: true }));
124
+ }
125
+ i += skip;
126
+ continue;
127
+ }
128
+
129
+ if (["--skill", "--prompt-template", "--theme"].includes(flagName)) {
130
+ const [value, skip] = getValue();
131
+ if (value !== undefined) alwaysProxy.push(flagName, resolvePathArg(value));
132
+ i += skip;
133
+ continue;
134
+ }
135
+
136
+ if (flagName === "--session-dir") {
137
+ const [value, skip] = getValue();
138
+ if (value !== undefined) {
139
+ alwaysProxy.push(flagName, resolvePathArg(value, { alwaysResolveRelative: true }));
140
+ }
141
+ i += skip;
142
+ continue;
143
+ }
144
+
145
+ if (
146
+ [
147
+ "--provider",
148
+ "--api-key",
149
+ "--system-prompt",
150
+ "--models",
151
+ ].includes(flagName)
152
+ ) {
153
+ const [value, skip] = getValue();
154
+ if (value !== undefined) alwaysProxy.push(flagName, value);
155
+ i += skip;
156
+ continue;
157
+ }
158
+
159
+ if (
160
+ [
161
+ "--no-skills",
162
+ "-ns",
163
+ "--no-prompt-templates",
164
+ "-np",
165
+ "--no-themes",
166
+ "--verbose",
167
+ ].includes(flagName)
168
+ ) {
169
+ alwaysProxy.push(flagName);
170
+ i++;
171
+ continue;
172
+ }
173
+
174
+ if (flagName === "--model") {
175
+ const [value, skip] = getValue();
176
+ if (value !== undefined) fallbackModel = value;
177
+ i += skip;
178
+ continue;
179
+ }
180
+
181
+ if (flagName === "--thinking") {
182
+ const [value, skip] = getValue();
183
+ if (value !== undefined) fallbackThinking = value;
184
+ i += skip;
185
+ continue;
186
+ }
187
+
188
+ if (flagName === "--tools") {
189
+ const [value, skip] = getValue();
190
+ if (value !== undefined) fallbackTools = value;
191
+ i += skip;
192
+ continue;
193
+ }
194
+
195
+ if (flagName === "--no-tools") {
196
+ fallbackNoTools = true;
197
+ i++;
198
+ continue;
199
+ }
200
+
201
+ if (inlineValue !== undefined) {
202
+ alwaysProxy.push(flagName, inlineValue);
203
+ i++;
204
+ continue;
205
+ }
206
+
207
+ if (nextIsValue) {
208
+ alwaysProxy.push(flagName, nextToken);
209
+ i += 2;
210
+ continue;
211
+ }
212
+
213
+ alwaysProxy.push(flagName);
214
+ i++;
215
+ }
216
+
217
+ return {
218
+ extensionArgs,
219
+ alwaysProxy,
220
+ fallbackModel,
221
+ fallbackThinking,
222
+ fallbackTools,
223
+ fallbackNoTools,
224
+ };
225
+ }