pi-agent-flow 0.1.0-alpha.2 → 0.1.0-alpha.4

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/flow.ts CHANGED
@@ -12,7 +12,7 @@ import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
13
  import type { FlowConfig } from "./agents.js";
14
14
  import { parseFlowCliArgs } from "./runner-cli.js";
15
- import { processFlowJsonLine } from "./runner-events.js";
15
+ import { processFlowJsonLine, drainStreamingText } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
18
18
  type FlowDetails,
@@ -224,11 +224,12 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
224
224
  };
225
225
 
226
226
  const emitUpdate = () => {
227
+ const streaming = drainStreamingText(result);
227
228
  onUpdate?.({
228
229
  content: [
229
230
  {
230
231
  type: "text",
231
- text: getFlowOutput(result.messages) || "(running...)",
232
+ text: streaming || getFlowOutput(result.messages) || "(running...)",
232
233
  },
233
234
  ],
234
235
  details: makeDetails([result]),
package/index.ts CHANGED
@@ -379,9 +379,8 @@ Multiple independent flows? Batch them into one call:
379
379
 
380
380
  Each call renders as:
381
381
 
382
- routing to:
383
- • flow [explore] — Map the full directory structure...
384
- • flow [review] — Audit security and quality...
382
+ flow [explore] — Map the full directory structure...
383
+ • flow [review] — Audit security and quality...
385
384
 
386
385
  Each flow returns:
387
386
 
@@ -489,10 +488,12 @@ flow [type] accomplished
489
488
  };
490
489
  }
491
490
 
492
- const emitProgress = () => {
491
+ let lastStreamingText = "";
492
+ const emitProgress = (streamingText?: string) => {
493
493
  if (!onUpdate) return;
494
+ if (streamingText) lastStreamingText = streamingText;
494
495
  onUpdate({
495
- content: [{ type: "text", text: `Flow: ${allResults.filter((r) => r.exitCode !== -1).length}/${allResults.length} done` }],
496
+ content: [{ type: "text", text: lastStreamingText || "" }],
496
497
  details: makeDetails([...allResults]),
497
498
  });
498
499
  };
@@ -527,7 +528,7 @@ flow [type] accomplished
527
528
  onUpdate: (partial) => {
528
529
  if (partial.details?.results[0]) {
529
530
  allResults[index] = partial.details.results[0];
530
- emitProgress();
531
+ emitProgress(partial.content?.[0]?.text);
531
532
  }
532
533
  },
533
534
  makeDetails,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -36,10 +36,11 @@
36
36
  },
37
37
  "scripts": {
38
38
  "lint": "tsc --noEmit",
39
- "test": "echo \"no tests yet\" && exit 0"
39
+ "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "typescript": "^5.0.0"
42
+ "typescript": "^5.0.0",
43
+ "vitest": "^3.2.4"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "@mariozechner/pi-agent-core": ">=0.37.0",
package/render.ts CHANGED
@@ -20,31 +20,7 @@ import {
20
20
  isFlowError,
21
21
  isFlowSuccess,
22
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
- }
23
+ import { formatTokens, formatFlowUsage, formatCompactStats, truncateChars, tailText } from "./render-utils.js";
48
24
 
49
25
  function shortenPath(p: string): string {
50
26
  const home = os.homedir();
@@ -52,6 +28,8 @@ function shortenPath(p: string): string {
52
28
  }
53
29
 
54
30
  type ThemeFg = (color: string, text: string) => string;
31
+ type ThemeBg = (color: string, text: string) => string;
32
+ type FlowTheme = { fg: ThemeFg; bold: (s: string) => string; bg: ThemeBg };
55
33
 
56
34
  function formatFlowToolCall(toolName: string, args: Record<string, unknown>, fg: ThemeFg): string {
57
35
  const pathArg = (args.file_path || args.path || "...") as string;
@@ -137,28 +115,11 @@ function truncateText(text: string): string {
137
115
  return `${words.slice(0, 3).join(" ")} ... ${words.slice(-8).join(" ")}`;
138
116
  }
139
117
 
140
- export function renderFlowCall(args: Record<string, any>, theme: { fg: ThemeFg; bold: (s: string) => string }): Text {
118
+ export function renderFlowCall(args: Record<string, any>, theme: FlowTheme): Text {
141
119
  const flows = args.flow as Array<{ type: string; intent: string }> | undefined;
142
120
 
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
- }
121
+ // Minimal renderFlowResult owns the full display
122
+ return new Text("", 0, 0);
162
123
 
163
124
  return new Text(theme.fg("muted", "(empty flow call)"), 0, 0);
164
125
  }
@@ -170,16 +131,17 @@ export function renderFlowCall(args: Record<string, any>, theme: { fg: ThemeFg;
170
131
  export function renderFlowResult(
171
132
  result: { content: Array<{ type: string; text?: string }>; details?: unknown },
172
133
  expanded: boolean,
173
- theme: { fg: ThemeFg; bold: (s: string) => string },
134
+ theme: FlowTheme,
174
135
  ): Container | Text {
175
136
  const details = result.details as FlowDetails | undefined;
137
+ const streamingText = result.content?.[0]?.type === "text" ? result.content[0].text : undefined;
138
+
176
139
  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);
140
+ return new Text(streamingText || "", 0, 0);
179
141
  }
180
142
 
181
143
  if (details.results.length === 1) {
182
- return renderSingleFlowResult(details.results[0], expanded, theme);
144
+ return renderSingleFlowResult(details.results[0], expanded, theme, streamingText);
183
145
  }
184
146
 
185
147
  return renderMultiFlowResult(details, expanded, theme);
@@ -192,7 +154,8 @@ export function renderFlowResult(
192
154
  function renderSingleFlowResult(
193
155
  r: SingleResult,
194
156
  expanded: boolean,
195
- theme: { fg: ThemeFg; bold: (s: string) => string },
157
+ theme: FlowTheme,
158
+ streamingText?: string,
196
159
  ): Container | Text {
197
160
  const error = isFlowError(r);
198
161
  const icon = flowStatusIcon(r, theme);
@@ -202,7 +165,7 @@ function renderSingleFlowResult(
202
165
  if (expanded) {
203
166
  return renderFlowExpanded(r, icon, error, displayItems, flowOutput, theme);
204
167
  }
205
- return renderFlowCollapsed(r, icon, error, flowOutput, theme);
168
+ return renderFlowCollapsed(r, icon, error, flowOutput, theme, streamingText);
206
169
  }
207
170
 
208
171
  function renderFlowExpanded(
@@ -211,7 +174,7 @@ function renderFlowExpanded(
211
174
  error: boolean,
212
175
  displayItems: DisplayItem[],
213
176
  flowOutput: string,
214
- theme: { fg: ThemeFg; bold: (s: string) => string },
177
+ theme: FlowTheme,
215
178
  ): Container {
216
179
  const mdTheme = getMarkdownTheme();
217
180
  const container = new Container();
@@ -257,24 +220,34 @@ function renderFlowExpanded(
257
220
  return container;
258
221
  }
259
222
 
223
+
224
+
260
225
  function renderFlowCollapsed(
261
226
  r: SingleResult,
262
227
  icon: string,
263
228
  error: boolean,
264
229
  flowOutput: string,
265
- theme: { fg: ThemeFg; bold: (s: string) => string },
230
+ theme: FlowTheme,
231
+ streamingText?: string,
266
232
  ): 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)}`;
233
+ const stats = formatCompactStats(r.usage, r.model);
234
+ let text = `${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
270
235
  if (error && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
271
236
 
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))}`;
237
+ // Intent line
238
+ const hasOutput = !!(flowOutput || streamingText || (error && r.errorMessage));
239
+ if (r.intent) {
240
+ const prefix = hasOutput ? "├" : "└";
241
+ text += `\n${theme.fg("dim", `${prefix} int:`)} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
242
+ }
243
+
244
+ // Output line
245
+ if (flowOutput) {
246
+ text += `\n${theme.fg("dim", "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
247
+ } else if (streamingText) {
248
+ text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", tailText(streamingText, 40))}`;
249
+ } else if (error && r.errorMessage) {
250
+ text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
278
251
  }
279
252
 
280
253
  return new Text(text, 0, 0);
@@ -287,7 +260,7 @@ function renderFlowCollapsed(
287
260
  function renderMultiFlowResult(
288
261
  details: FlowDetails,
289
262
  expanded: boolean,
290
- theme: { fg: ThemeFg; bold: (s: string) => string },
263
+ theme: FlowTheme,
291
264
  ): Container | Text {
292
265
  const results = details.results;
293
266
  const successCount = results.filter((r) => isFlowSuccess(r)).length;
@@ -304,7 +277,7 @@ function renderMultiFlowExpanded(
304
277
  results: SingleResult[],
305
278
  successCount: number,
306
279
  icon: string,
307
- theme: { fg: ThemeFg; bold: (s: string) => string },
280
+ theme: FlowTheme,
308
281
  ): Container {
309
282
  const mdTheme = getMarkdownTheme();
310
283
  const container = new Container();
@@ -353,20 +326,43 @@ function renderMultiFlowCollapsed(
353
326
  results: SingleResult[],
354
327
  successCount: number,
355
328
  icon: string,
356
- theme: { fg: ThemeFg; bold: (s: string) => string },
329
+ theme: FlowTheme,
357
330
  ): Text {
358
331
  let text = `${icon} ${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", `${successCount}/${results.length} flows`)}`;
359
332
 
360
- for (const r of results) {
361
- const rIcon = flowStatusIcon(r, theme);
333
+ for (let i = 0; i < results.length; i++) {
334
+ const r = results[i];
335
+ const isLast = i === results.length - 1;
362
336
  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)}`;
337
+ const stats = formatCompactStats(r.usage, r.model);
338
+ const error = isFlowError(r);
339
+
340
+ // Header line
341
+ const headerPrefix = isLast ? "└─" : "├─";
342
+ let line = `\n${theme.fg("dim", headerPrefix)} ${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))}`;
343
+ if (stats) {
344
+ line += ` ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
345
+ }
346
+ if (error && r.stopReason) {
347
+ line += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
348
+ }
349
+ text += line;
350
+
351
+ // Continuation indent for sub-lines
352
+ const indent = isLast ? " " : "│ ";
353
+
354
+ // Intent line
355
+ const hasOutput = !!(flowOutput || (error && r.errorMessage));
356
+ if (r.intent) {
357
+ const intentPrefix = hasOutput ? "├─" : "└─";
358
+ text += `\n${theme.fg("dim", indent + intentPrefix + " int:")} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
359
+ }
360
+
361
+ // Output line
366
362
  if (flowOutput) {
367
- text += `\n${renderFlowReport(truncateText(flowOutput), theme)}`;
368
- } else {
369
- text += `\n${theme.fg("muted", getFlowSummaryText(r))}`;
363
+ text += `\n${theme.fg("dim", indent + "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
364
+ } else if (error && r.errorMessage) {
365
+ text += `\n${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
370
366
  }
371
367
  }
372
368
 
package/runner-events.js CHANGED
@@ -14,6 +14,51 @@ function getSeenFlowMessageSignatures(result) {
14
14
  return result.__seenMessageSignatures;
15
15
  }
16
16
 
17
+ function getStreamingTextBuffer(result) {
18
+ if (!Object.prototype.hasOwnProperty.call(result, "__streamingTextBuffer")) {
19
+ Object.defineProperty(result, "__streamingTextBuffer", {
20
+ value: "",
21
+ enumerable: false,
22
+ configurable: false,
23
+ writable: true,
24
+ });
25
+ Object.defineProperty(result, "__lastEmittedWordCount", {
26
+ value: 0,
27
+ enumerable: false,
28
+ configurable: false,
29
+ writable: true,
30
+ });
31
+ }
32
+ return result.__streamingTextBuffer;
33
+ }
34
+
35
+ /**
36
+ * Drain the accumulated streaming text buffer and return it.
37
+ * Updates the last-emitted word count for threshold tracking.
38
+ */
39
+ export function drainStreamingText(result) {
40
+ const buf = getStreamingTextBuffer(result);
41
+ if (!buf) return "";
42
+ result.__streamingTextBuffer = "";
43
+ result.__lastEmittedWordCount = 0;
44
+ return buf;
45
+ }
46
+
47
+ /**
48
+ * Accumulate a text or thinking delta into the streaming buffer.
49
+ * Returns true if the caller should emit an update.
50
+ */
51
+ function accumulateStreamingDelta(result, delta) {
52
+ if (!delta) return false;
53
+ const buf = getStreamingTextBuffer(result);
54
+ result.__streamingTextBuffer = buf + delta;
55
+ if (result.__streamingTextBuffer.length - result.__lastEmittedWordCount >= 40) {
56
+ result.__lastEmittedWordCount = result.__streamingTextBuffer.length;
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+
17
62
  function stableStringify(value) {
18
63
  if (value === null || typeof value !== "object") {
19
64
  return JSON.stringify(value);
@@ -98,6 +143,18 @@ export function processFlowEvent(event, result) {
98
143
  result.sawAgentEnd = true;
99
144
  return addFlowAssistantMessages(result, event.messages);
100
145
 
146
+ case "message_update": {
147
+ const evt = event.assistantMessageEvent;
148
+ if (!evt || typeof evt !== "object") return false;
149
+ if (evt.type === "text_delta") {
150
+ return accumulateStreamingDelta(result, evt.delta);
151
+ }
152
+ if (evt.type === "thinking_delta") {
153
+ return accumulateStreamingDelta(result, evt.delta);
154
+ }
155
+ return false;
156
+ }
157
+
101
158
  default:
102
159
  return false;
103
160
  }