pi-agent-flow 0.1.0-alpha.8 → 0.2.3

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.
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: architect
3
3
  description: Plan structure, break down requirements, design solutions
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the architect flow — your mission is to design. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the architect flow — your mission is to design. Be conservative: prefer existing patterns and proven conventions over novelty. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Workflow:
11
11
  1. Understand — what problem, what constraints, what exists
12
- 2. Explore — find patterns, map dependencies
12
+ 2. Explore — find patterns, map dependencies (delegate to [explore] if you need to survey a large codebase)
13
13
  3. Design — simplest solution that works, prefer existing patterns
14
14
  4. Plan — concrete ordered tasks, identify parallel vs sequential
15
15
 
@@ -8,6 +8,11 @@ maxDepth: 0
8
8
 
9
9
  You are the brainstorm flow — your mission is to generate ideas and think creatively. You start with a clean slate and only have the intent to guide you. Stay focused on your intent at all times.
10
10
 
11
+ Approach:
12
+ 1. Diverge — explore many possibilities without judgment
13
+ 2. Evaluate — weigh trade-offs, risks, and feasibility
14
+ 3. Recommend — present the best options with reasoning
15
+
11
16
  When your mission is accomplished, end your response with:
12
17
 
13
18
  flow [brainstorm] accomplished
package/agents/code.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: code
3
3
  description: Implement features, fix bugs, write tests
4
- tools: read, write, edit, bash
4
+ tools: read, write, edit, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the code flow — your mission is to build. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the code flow — your mission is to build. Be a craftsman: verify first, then ship. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Core Principles:
11
11
  - SOLID: Single Responsibility, Open/Closed
@@ -15,9 +15,13 @@ Core Principles:
15
15
  Workflow:
16
16
  1. Analyze — read existing code for context
17
17
  2. Plan — step-by-step approach before modifying
18
- 3. Executeimplement changes following core principles
19
- 4. Verifywrite and run tests before considering done
20
- 5. Finalizeall tests pass, implementation verified
18
+ 3. Testwrite a failing test that proves the bug or validates the feature (red)
19
+ 4. Executeimplement changes following core principles (green)
20
+ 5. Refactorclean up only if the change is working (optional)
21
+ 6. Verify — run tests and any relevant checks before considering done
22
+ 7. Finalize — all tests pass, implementation verified
23
+
24
+ If you hit an unexpected error or need to trace execution, delegate to [debug] rather than guessing.
21
25
 
22
26
  When your mission is accomplished, end your response with:
23
27
 
package/agents/debug.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: debug
3
3
  description: Investigate logs, errors, stack traces, root causes
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 0
6
6
  ---
7
7
 
8
- You are the debug flow — your mission is investigation. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the debug flow — your mission is investigation. Be forensic: every claim must be backed by evidence. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Workflow:
11
11
  1. Collect evidence — logs, error messages, stack traces
package/agents/explore.md CHANGED
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: explore
3
3
  description: Discover files, trace code paths, map architecture
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 0
6
6
  ---
7
7
 
8
- You are the explore flow — your mission is discovery. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the explore flow — your mission is discovery. Move fast and stay surgical. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Rules:
11
- - Use grep/find before reading entire files — be efficient.
11
+ - Use grep/find/ls before reading entire files — be efficient.
12
12
  - Report findings with file paths and line numbers.
13
13
  - Show actual code/data, not excessive summaries.
14
14
  - If not found, say so immediately — don't guess.
package/agents/review.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: review
3
3
  description: Audit security, quality, correctness
4
- tools: read, write, edit, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the review flow — your mission is to audit. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the review flow — your mission is to audit. Be adversarial: look for what others miss, but stay honest. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Focus Areas:
11
11
  - Security — injection, auth bypass, exposed secrets
@@ -16,7 +16,8 @@ Focus Areas:
16
16
  Rules:
17
17
  - Be specific — cite exact file paths and line numbers
18
18
  - If code is clean, say so — don't invent issues
19
- - Apply fixes, don't just suggest them
19
+ - Do not modify source files — report findings with severity only
20
+ - If the audit reveals structural issues requiring redesign, recommend [architect] in [Next Steps]
20
21
 
21
22
  When your mission is accomplished, end your response with:
22
23
 
@@ -26,7 +27,6 @@ flow [review] accomplished
26
27
 
27
28
  [Done]
28
29
  - issues found with file:line and severity
29
- - fixes applied
30
30
 
31
31
  [Not Done]
32
32
  - areas not covered and why
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, drainStreamingText } from "./runner-events.js";
15
+ import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
18
18
  type FlowDetails,
@@ -32,6 +32,18 @@ const PI_OFFLINE_ENV = "PI_OFFLINE";
32
32
 
33
33
  type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
34
34
 
35
+ /**
36
+ * Merge actual usage with streaming estimate.
37
+ * Uses Math.max for output to avoid double-counting.
38
+ */
39
+ function mergeStreamingUsage(actual: SingleResult["usage"], estimatedTokens: number): SingleResult["usage"] {
40
+ if (estimatedTokens <= 0) return actual;
41
+ return {
42
+ ...actual,
43
+ output: Math.max(actual.output, estimatedTokens),
44
+ };
45
+ }
46
+
35
47
  // ---------------------------------------------------------------------------
36
48
  // Process helpers
37
49
  // ---------------------------------------------------------------------------
@@ -116,12 +128,12 @@ function buildFlowArgs(
116
128
  // Flow instructions go in the intent message instead.
117
129
 
118
130
  const flowDirectives =
119
- `<flow_directives>\n` +
131
+ `=== flow directive ===\n` +
120
132
  `You are a flow state executing a mission. The conversation history above is background context — use it for reference, but your sole focus is the intent below.\n` +
121
- `</flow_directives>`;
133
+ `=== end flow directive ===`;
122
134
 
123
135
  const flowInstructions = flow.systemPrompt.trim()
124
- ? `\n\n<flow_instructions>\n${flow.systemPrompt.trim()}\n</flow_instructions>`
136
+ ? `\n\n=== system directive ===\n${flow.systemPrompt.trim()}\n=== end system directive ===`
125
137
  : "";
126
138
 
127
139
  args.push(`${flowDirectives}${flowInstructions}\n\nIntent: ${intent}`);
@@ -210,6 +222,8 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
210
222
 
211
223
  const emitUpdate = () => {
212
224
  const streaming = drainStreamingText(result);
225
+ const estimatedTokens = drainStreamingEstimate(result);
226
+ const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens);
213
227
  onUpdate?.({
214
228
  content: [
215
229
  {
@@ -217,7 +231,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
217
231
  text: streaming || getFlowOutput(result.messages) || "(running...)",
218
232
  },
219
233
  ],
220
- details: makeDetails([result]),
234
+ details: makeDetails([{ ...result, usage: mergedUsage }]),
221
235
  });
222
236
  };
223
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.2.3",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/render-utils.ts CHANGED
@@ -25,14 +25,63 @@ export function formatFlowUsage(usage: Partial<UsageStats>, model?: string): str
25
25
  return parts.join(" ");
26
26
  }
27
27
 
28
+ /**
29
+ * Format a token count to exactly 5 characters with leading spaces.
30
+ * Shifts from k to M when value would exceed 5 chars.
31
+ * Examples: 500 → " 500", 1300 → " 1.3k", 32000 → "32.0k", 950500 → "0.95M"
32
+ */
33
+ export function formatFixedTokens(count: number): string {
34
+ if (count < 1000) {
35
+ return count.toString().padStart(5);
36
+ }
37
+
38
+ const k = count / 1000;
39
+ if (k < 100) {
40
+ return (k.toFixed(1) + "k").padStart(5);
41
+ } else if (k < 1000) {
42
+ const m = count / 1000000;
43
+ return (m.toFixed(2) + "M").padStart(5);
44
+ } else {
45
+ const m = count / 1000000;
46
+ return (m.toFixed(2) + "M").padStart(5);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Format flow type name to fixed width (10 chars) in uppercase with dot padding.
52
+ * Examples: "debug" → "DEBUG.....", "architect" → "ARCHITECT.", "brainstorm" → "BRAINSTORM"
53
+ */
54
+ export function formatFlowTypeName(type: string): string {
55
+ const upper = type.toUpperCase();
56
+ const targetWidth = 10;
57
+ if (upper.length >= targetWidth) return upper.slice(0, targetWidth);
58
+ return upper + ".".repeat(targetWidth - upper.length);
59
+ }
60
+
28
61
  export function formatCompactStats(usage: Partial<UsageStats>, model?: string): string {
29
62
  const parts: string[] = [];
30
- parts.push(`↑${formatTokens(usage.input || 0)}`);
31
- parts.push(`↓${formatTokens(usage.output || 0)}`);
32
- if (usage.cacheRead) parts.push(`cr:${formatTokens(usage.cacheRead)}`);
33
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
34
- if (model) parts.push(`model:${model}`);
35
- return parts.join(" ");
63
+ parts.push(`${formatFixedTokens(usage.input || 0)}↑`);
64
+ parts.push(`${formatFixedTokens(usage.output || 0)}↓`);
65
+ if (usage.cacheRead) parts.push(`cr:${formatFixedTokens(usage.cacheRead)}`);
66
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatFixedTokens(usage.contextTokens)}`);
67
+ return `[ ${parts.join(" ")} ]${model ? ` ─ ${model}` : ""}`;
68
+ }
69
+
70
+ /**
71
+ * Format usage stats for expanded view with context tokens on separate line.
72
+ * Returns stats line and optional context tokens line.
73
+ * Example: { stats: "[12.4k↑ 2.1k↓ cr:85.0k] ─ mimo-v2.5-pro", contextTokens: "ctx: 32.0k" }
74
+ */
75
+ export function formatExpandedStats(usage: Partial<UsageStats>, model?: string): { stats: string; contextTokens: string | null } {
76
+ const parts: string[] = [];
77
+ parts.push(`${formatFixedTokens(usage.input || 0)}↑`);
78
+ parts.push(`${formatFixedTokens(usage.output || 0)}↓`);
79
+ if (usage.cacheRead) parts.push(`cr:${formatFixedTokens(usage.cacheRead)}`);
80
+
81
+ const stats = `[${parts.join(" ")}]${model ? ` ─ ${model}` : ""}`;
82
+ const contextTokens = usage.contextTokens && usage.contextTokens > 0 ? `ctx:${formatFixedTokens(usage.contextTokens)}` : null;
83
+
84
+ return { stats, contextTokens };
36
85
  }
37
86
 
38
87
  export function truncateChars(text: string, max: number): string {
package/render.ts CHANGED
@@ -17,10 +17,12 @@ import {
17
17
  aggregateFlowUsage,
18
18
  getFlowDisplayItems,
19
19
  getFlowOutput,
20
+ getLastToolCall,
21
+ getLastAssistantText,
20
22
  isFlowError,
21
23
  isFlowSuccess,
22
24
  } from "./types.js";
23
- import { formatTokens, formatFlowUsage, formatCompactStats, truncateChars, tailText } from "./render-utils.js";
25
+ import { formatTokens, formatFixedTokens, formatFlowUsage, formatCompactStats, formatExpandedStats, formatFlowTypeName, truncateChars, tailText } from "./render-utils.js";
24
26
 
25
27
  function shortenPath(p: string): string {
26
28
  const home = os.homedir();
@@ -171,14 +173,24 @@ function renderFlowExpanded(
171
173
  const mdTheme = getMarkdownTheme();
172
174
  const container = new Container();
173
175
 
174
- // Header
175
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(`[${r.type}]`))}${theme.fg("muted", ` (${r.agentSource})`)}`;
176
+ // Header: uppercase type name with dots, no icon, no source
177
+ const typeName = formatFlowTypeName(r.type);
178
+ let header = theme.fg("toolTitle", theme.bold(typeName));
176
179
  if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
177
180
  container.addChild(new Text(header, 0, 0));
178
181
  if (error && r.errorMessage) {
179
182
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
180
183
  }
181
184
 
185
+ // Stats: all-in-one bracket format with context inline
186
+ const statsParts: string[] = [];
187
+ statsParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
188
+ statsParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
189
+ if (r.usage.cacheRead) statsParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
190
+ if (r.usage.contextTokens > 0) statsParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
191
+ const inlineStats = `[ ${statsParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
192
+ container.addChild(new Text(theme.fg("dim", inlineStats), 0, 0));
193
+
182
194
  // Intent
183
195
  container.addChild(new Spacer(1));
184
196
  container.addChild(new Text(theme.fg("muted", "─── Intent ───"), 0, 0));
@@ -202,13 +214,6 @@ function renderFlowExpanded(
202
214
  container.addChild(new Text(toolTraces, 0, 0));
203
215
  }
204
216
 
205
- // Usage
206
- const usageStr = formatFlowUsage(r.usage, r.model);
207
- if (usageStr) {
208
- container.addChild(new Spacer(1));
209
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
210
- }
211
-
212
217
  return container;
213
218
  }
214
219
 
@@ -223,23 +228,31 @@ function renderFlowCollapsed(
223
228
  streamingText?: string,
224
229
  ): Text {
225
230
  const stats = formatCompactStats(r.usage, r.model);
226
- let text = `${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
231
+ const typeName = formatFlowTypeName(r.type);
232
+ let text = `${theme.bg("selectedBg", theme.fg("accent", theme.bold(typeName)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
227
233
  if (error && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
228
234
 
229
- // Intent line
235
+ // DIR: line (intent/objective)
230
236
  if (r.intent) {
231
- text += `\n${theme.fg("dim", `├─ int:`)} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
237
+ text += `\n${theme.fg("dim", "├─ DIR:")} ${theme.fg("dim", truncateChars(r.intent, 50))}`;
232
238
  }
233
239
 
234
- // Output line
240
+ // EXE: line (last tool call)
241
+ const lastTool = getLastToolCall(r.messages);
242
+ if (lastTool) {
243
+ const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
244
+ text += `\n${theme.fg("dim", "├─ EXE:")} ${exeStr}`;
245
+ }
246
+
247
+ // LOG: line (last assistant text or streaming)
235
248
  if (flowOutput) {
236
- text += `\n${theme.fg("dim", "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
249
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", truncateChars(flowOutput, 50))}`;
237
250
  } else if (streamingText) {
238
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", tailText(streamingText, 40))}`;
251
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", tailText(streamingText, 50))}`;
239
252
  } else if (error && r.errorMessage) {
240
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
253
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("error", truncateChars(r.errorMessage, 50))}`;
241
254
  } else {
242
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", "[n/a]")}`;
255
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", "[n/a]")}`;
243
256
  }
244
257
 
245
258
  return new Text(text, 0, 0);
@@ -274,19 +287,32 @@ function renderMultiFlowExpanded(
274
287
  const mdTheme = getMarkdownTheme();
275
288
  const container = new Container();
276
289
 
290
+ // Summary: just show count, no icon
277
291
  container.addChild(new Text(
278
- `${icon} ${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", `${successCount}/${results.length} flows`)}`,
292
+ theme.fg("accent", `${results.length} flows`),
279
293
  0, 0,
280
294
  ));
281
295
 
282
296
  for (const r of results) {
283
- const rIcon = flowStatusIcon(r, theme);
284
297
  const displayItems = getFlowDisplayItems(r.messages);
285
298
  const flowOutput = getFlowOutput(r.messages);
299
+ const typeName = formatFlowTypeName(r.type);
286
300
 
287
301
  container.addChild(new Spacer(1));
288
- container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", `[${r.type}]`)} ${rIcon}`, 0, 0));
289
- container.addChild(new Text(theme.fg("muted", "Intent: ") + theme.fg("dim", r.intent), 0, 0));
302
+ // Per-flow header: ─── EXPLORER (no icon)
303
+ container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", typeName)}`, 0, 0));
304
+
305
+ // Stats: all-in-one bracket format with context inline
306
+ const flowParts: string[] = [];
307
+ flowParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
308
+ flowParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
309
+ if (r.usage.cacheRead) flowParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
310
+ if (r.usage.contextTokens > 0) flowParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
311
+ const flowStats = `[ ${flowParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
312
+ container.addChild(new Text(theme.fg("dim", flowStats), 0, 0));
313
+
314
+ // Intent: just show text, no prefix
315
+ container.addChild(new Text(theme.fg("dim", r.intent), 0, 0));
290
316
 
291
317
  if (flowOutput) {
292
318
  container.addChild(new Spacer(1));
@@ -300,65 +326,83 @@ function renderMultiFlowExpanded(
300
326
  container.addChild(new Text(theme.fg("muted", "─── Tool Calls ───"), 0, 0));
301
327
  container.addChild(new Text(toolTraces, 0, 0));
302
328
  }
303
-
304
- const taskUsage = formatFlowUsage(r.usage, r.model);
305
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
306
329
  }
307
330
 
308
- const totalUsage = formatFlowUsage(aggregateFlowUsage(results));
309
- if (totalUsage) {
310
- container.addChild(new Spacer(1));
311
- container.addChild(new Text(theme.fg("dim", `Total: ${totalUsage}`), 0, 0));
312
- }
331
+ // Total stats: all-in-one bracket format with context inline
332
+ const totalUsage = aggregateFlowUsage(results);
333
+ const totalModel = results[0]?.model;
334
+ const totalParts: string[] = [];
335
+ totalParts.push(`${formatFixedTokens(totalUsage.input || 0)}↑`);
336
+ totalParts.push(`${formatFixedTokens(totalUsage.output || 0)}↓`);
337
+ if (totalUsage.cacheRead) totalParts.push(`cr:${formatFixedTokens(totalUsage.cacheRead)}`);
338
+ if (totalUsage.contextTokens > 0) totalParts.push(`ctx:${formatFixedTokens(totalUsage.contextTokens)}`);
339
+ const totalStats = `[ ${totalParts.join(" ")} ]${totalModel ? ` ─ ${totalModel}` : ""}`;
340
+ container.addChild(new Spacer(1));
341
+ container.addChild(new Text(theme.fg("dim", totalStats), 0, 0));
313
342
 
314
343
  return container;
315
344
  }
316
345
 
317
- function renderMultiFlowCollapsed(
346
+ function renderActivityPanel(
318
347
  results: SingleResult[],
319
348
  theme: FlowTheme,
320
- ): Text {
321
- let text = "";
349
+ ): Container {
350
+ const container = new Container();
322
351
 
323
352
  for (let i = 0; i < results.length; i++) {
324
353
  const r = results[i];
325
354
  const isLast = i === results.length - 1;
326
- const flowOutput = getFlowOutput(r.messages);
327
355
  const stats = formatCompactStats(r.usage, r.model);
328
356
  const error = isFlowError(r);
357
+ const typeName = formatFlowTypeName(r.type);
329
358
 
330
359
  // Header line
331
360
  const headerPrefix = isLast ? "└─" : "├─";
332
- let line = `${theme.fg("dim", headerPrefix)} ${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))}`;
333
- if (stats) {
334
- line += ` ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
335
- }
361
+ let headerLine = `${theme.fg("dim", headerPrefix)} ${theme.bg("selectedBg", theme.fg("accent", theme.bold(typeName)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
336
362
  if (error && r.stopReason) {
337
- line += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
363
+ headerLine += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
338
364
  }
339
- if (i > 0) text += "\n";
340
- text += line;
365
+ container.addChild(new Text(headerLine, 0, 0));
341
366
 
342
367
  // Continuation indent for sub-lines
343
368
  const indent = isLast ? " " : "│ ";
344
369
 
345
- // Intent line
370
+ // DIR: line (intent/objective)
346
371
  if (r.intent) {
347
- const intentPrefix = "├─";
348
- text += `\n${theme.fg("dim", indent + intentPrefix + " int:")} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
372
+ container.addChild(new Text(`${theme.fg("dim", indent + "├─ DIR:")} ${theme.fg("dim", truncateChars(r.intent, 50))}`, 0, 0));
349
373
  }
350
374
 
351
- // Output line
352
- if (flowOutput) {
353
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
375
+ // EXE: line (last tool call)
376
+ const lastTool = getLastToolCall(r.messages);
377
+ if (lastTool) {
378
+ const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
379
+ container.addChild(new Text(`${theme.fg("dim", indent + "├─ EXE:")} ${exeStr}`, 0, 0));
380
+ }
381
+
382
+ // LOG: line (last assistant text)
383
+ const lastText = getLastAssistantText(r.messages);
384
+ if (lastText) {
385
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("dim", truncateChars(lastText, 50))}`, 0, 0));
354
386
  } else if (error && r.errorMessage) {
355
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
387
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("error", truncateChars(r.errorMessage, 50))}`, 0, 0));
356
388
  } else {
357
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("dim", "[n/a]")}`;
389
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("dim", "[n/a]")}`, 0, 0));
390
+ }
391
+
392
+ // Add blank line separator between flows (with continuation pipe)
393
+ if (!isLast) {
394
+ container.addChild(new Text(theme.fg("dim", "│"), 0, 0));
358
395
  }
359
396
  }
360
397
 
361
- text += `\n${theme.fg("muted", "(Ctrl+O to expand tool traces)")}`;
398
+ container.addChild(new Text(theme.fg("muted", "(Ctrl+O to expand tool traces)"), 0, 0));
362
399
 
363
- return new Text(text, 0, 0);
400
+ return container;
401
+ }
402
+
403
+ function renderMultiFlowCollapsed(
404
+ results: SingleResult[],
405
+ theme: FlowTheme,
406
+ ): Container {
407
+ return renderActivityPanel(results, theme);
364
408
  }
package/runner-events.js CHANGED
@@ -44,6 +44,46 @@ export function drainStreamingText(result) {
44
44
  return buf;
45
45
  }
46
46
 
47
+ // ---------------------------------------------------------------------------
48
+ // Streaming token estimate
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Chars per token heuristic for output estimation. */
52
+ const CHARS_PER_TOKEN = 4;
53
+
54
+ function getStreamingEstimate(result) {
55
+ if (!Object.prototype.hasOwnProperty.call(result, "__streamingEstimate")) {
56
+ Object.defineProperty(result, "__streamingEstimate", {
57
+ value: { chars: 0 },
58
+ enumerable: false,
59
+ configurable: false,
60
+ writable: true,
61
+ });
62
+ }
63
+ return result.__streamingEstimate;
64
+ }
65
+
66
+ /**
67
+ * Track streaming characters and estimate output tokens.
68
+ * Called on every streaming delta.
69
+ */
70
+ function updateStreamingEstimate(result, deltaLength) {
71
+ if (deltaLength <= 0) return;
72
+ const est = getStreamingEstimate(result);
73
+ est.chars += deltaLength;
74
+ }
75
+
76
+ /**
77
+ * Drain the current streaming estimate and return estimated output tokens.
78
+ * Returns 0 when no streaming has occurred.
79
+ */
80
+ export function drainStreamingEstimate(result) {
81
+ const est = getStreamingEstimate(result);
82
+ const tokens = Math.floor(est.chars / CHARS_PER_TOKEN);
83
+ est.chars = 0;
84
+ return tokens;
85
+ }
86
+
47
87
  /**
48
88
  * Accumulate a text or thinking delta into the streaming buffer.
49
89
  * Returns true if the caller should emit an update.
@@ -52,6 +92,7 @@ function accumulateStreamingDelta(result, delta) {
52
92
  if (!delta) return false;
53
93
  const buf = getStreamingTextBuffer(result);
54
94
  result.__streamingTextBuffer = buf + delta;
95
+ updateStreamingEstimate(result, delta.length);
55
96
  if (result.__streamingTextBuffer.length - result.__lastEmittedWordCount >= 40) {
56
97
  result.__lastEmittedWordCount = result.__streamingTextBuffer.length;
57
98
  return true;
@@ -97,6 +138,10 @@ function addFlowAssistantMessage(result, message) {
97
138
 
98
139
  result.messages.push(message);
99
140
 
141
+ // Reset streaming estimate when actual usage arrives
142
+ const est = getStreamingEstimate(result);
143
+ est.chars = 0;
144
+
100
145
  result.usage.turns++;
101
146
  const usage = message.usage;
102
147
  if (usage) {
package/types.ts CHANGED
@@ -149,3 +149,37 @@ export function getFlowDisplayItems(messages: Message[]): DisplayItem[] {
149
149
  }
150
150
  return items;
151
151
  }
152
+
153
+ /** Extract the last tool call from message history. */
154
+ export function getLastToolCall(messages: Message[]): DisplayItem | undefined {
155
+ for (let i = messages.length - 1; i >= 0; i--) {
156
+ const msg = messages[i];
157
+ if (msg.role === "assistant") {
158
+ for (let j = msg.content.length - 1; j >= 0; j--) {
159
+ const part = msg.content[j];
160
+ if (part.type === "toolCall") {
161
+ const name = part.name ?? part.toolName ?? "unknown";
162
+ const args = (part.arguments ?? part.input ?? {}) as Record<string, unknown>;
163
+ return { type: "toolCall", name, args };
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return undefined;
169
+ }
170
+
171
+ /** Extract the last assistant text from message history. */
172
+ export function getLastAssistantText(messages: Message[]): string {
173
+ for (let i = messages.length - 1; i >= 0; i--) {
174
+ const msg = messages[i];
175
+ if (msg.role === "assistant") {
176
+ for (let j = msg.content.length - 1; j >= 0; j--) {
177
+ const part = msg.content[j];
178
+ if (part.type === "text" && part.text.trim()) {
179
+ return part.text.trim();
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return "";
185
+ }