pi-workflow-engine 0.13.0 → 1.0.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.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -0,0 +1,193 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { formatCount } from "../text.ts";
4
+ import type { WorkflowUsageSnapshot } from "../usage.ts";
5
+
6
+ /**
7
+ * The extension's shared visual language. Every screen draws status, severity, usage, hints and
8
+ * frames through these helpers so the live widget, result messages and overlays read as one product.
9
+ */
10
+
11
+ export type ThemeColor = Parameters<Theme["fg"]>[0];
12
+
13
+ export const GLYPH = {
14
+ done: "✓",
15
+ failed: "✗",
16
+ running: "●",
17
+ queued: "○",
18
+ warning: "⚠",
19
+ bullet: "•",
20
+ cursor: "›",
21
+ workflow: "◆",
22
+ arrow: "→",
23
+ } as const;
24
+
25
+ export const SEPARATOR = " · ";
26
+
27
+ export function dot(theme: Theme): string {
28
+ return theme.fg("dim", SEPARATOR);
29
+ }
30
+
31
+ export function joinParts(parts: readonly (string | undefined | false)[], theme: Theme): string {
32
+ return parts.filter((part): part is string => typeof part === "string" && part.length > 0).join(dot(theme));
33
+ }
34
+
35
+ export type Severity = "high" | "medium" | "low";
36
+
37
+ export function severityColor(severity: Severity): ThemeColor {
38
+ return severity === "high" ? "error" : severity === "medium" ? "warning" : "muted";
39
+ }
40
+
41
+ const SEVERITY_LABEL: Record<Severity, string> = { high: "HIGH", medium: "MED", low: "LOW" };
42
+
43
+ /** Fixed-width (4 columns) severity badge so finding rows align. */
44
+ export function severityBadge(severity: Severity, theme: Theme): string {
45
+ const label = SEVERITY_LABEL[severity].padEnd(4, " ");
46
+ return severity === "high" ? theme.fg("error", theme.bold(label)) : theme.fg(severityColor(severity), label);
47
+ }
48
+
49
+ export function severityRank(severity: Severity): number {
50
+ return severity === "high" ? 0 : severity === "medium" ? 1 : 2;
51
+ }
52
+
53
+ /** `━━━━━───` style bar; empty when there is nothing to measure. */
54
+ export function progressBar(done: number, total: number, width: number, theme: Theme): string {
55
+ if (total <= 0 || width <= 0) return "";
56
+ const filled = Math.round((Math.min(done, total) / total) * width);
57
+ return theme.fg("accent", "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
58
+ }
59
+
60
+ /** `↑34k ↓1.2k · $0.02`: input (fresh + cache) and output tokens, cost only when known. */
61
+ export function compactUsage(usage: WorkflowUsageSnapshot | undefined, theme: Theme): string | undefined {
62
+ if (!usage || usage.totals.totalTokens <= 0) return undefined;
63
+ const { input, cacheRead, cacheWrite, output, cost } = usage.totals;
64
+ const parts = [`↑${formatCount(input + cacheRead + cacheWrite)}`, `↓${formatCount(output)}`].join(" ");
65
+ return cost.total > 0 ? joinParts([parts, formatCost(cost.total)], theme) : parts;
66
+ }
67
+
68
+ export function formatCost(dollars: number): string {
69
+ if (dollars <= 0) return "$0";
70
+ if (dollars < 0.01) return "<$0.01";
71
+ return `$${dollars < 10 ? dollars.toFixed(2) : dollars.toFixed(1)}`;
72
+ }
73
+
74
+ /** `5s ago`, `3m ago`, `2h ago`, `4d ago`. */
75
+ export function relativeTime(fromMs: number, nowMs = Date.now()): string {
76
+ const seconds = Math.max(0, Math.round((nowMs - fromMs) / 1000));
77
+ if (seconds < 60) return `${seconds}s ago`;
78
+ const minutes = Math.round(seconds / 60);
79
+ if (minutes < 60) return `${minutes}m ago`;
80
+ const hours = Math.round(minutes / 60);
81
+ if (hours < 48) return `${hours}h ago`;
82
+ return `${Math.round(hours / 24)}d ago`;
83
+ }
84
+
85
+ /** Keeps the file name and line: `src/very/deep/file.ts:12` → `…/deep/file.ts:12`. */
86
+ export function truncatePath(path: string, width: number): string {
87
+ if (visibleWidth(path) <= width) return path;
88
+ if (width <= 1) return "…".slice(0, width);
89
+ const segments = path.split("/");
90
+ let tail = segments.pop() ?? path;
91
+ while (segments.length > 0 && visibleWidth(`…/${segments[segments.length - 1]}/${tail}`) <= width) {
92
+ tail = `${segments.pop()}/${tail}`;
93
+ }
94
+ const candidate = `…/${tail}`;
95
+ return visibleWidth(candidate) <= width ? candidate : `…${tail.slice(-(width - 1))}`;
96
+ }
97
+
98
+ /** Pads (or truncates) styled text to an exact visible width. */
99
+ export function fit(text: string, width: number): string {
100
+ if (width <= 0) return "";
101
+ const truncated = truncateToWidth(text, width, "…");
102
+ return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
103
+ }
104
+
105
+ export function padStartVisible(text: string, width: number): string {
106
+ return " ".repeat(Math.max(0, width - visibleWidth(text))) + text;
107
+ }
108
+
109
+ /** Left and right content on one line, the left side truncated first. */
110
+ export function spread(left: string, right: string, width: number): string {
111
+ const rightWidth = visibleWidth(right);
112
+ if (rightWidth >= width) return truncateToWidth(right, width, "…");
113
+ const leftRoom = width - rightWidth - 1;
114
+ return fit(left, leftRoom) + " " + right;
115
+ }
116
+
117
+ /** Wraps `text` to `width`, prefixing the first line with `first` and the rest with matching spaces. */
118
+ export function hangingWrap(text: string, width: number, first = "", rest = " ".repeat(visibleWidth(first))): string[] {
119
+ const available = Math.max(8, width - visibleWidth(first));
120
+ const lines = wrapTextWithAnsi(text, available);
121
+ if (lines.length === 0) return [first.trimEnd()];
122
+ return lines.map((line, index) => `${index === 0 ? first : rest}${line}`);
123
+ }
124
+
125
+ /** `↑↓ move · enter open · q close` in pi's hint style (dim key, muted description). */
126
+ export function keyHints(pairs: readonly (readonly [key: string, description: string])[], theme: Theme): string {
127
+ return pairs.map(([key, description]) => `${theme.fg("dim", key)} ${theme.fg("muted", description)}`).join(dot(theme));
128
+ }
129
+
130
+ export interface Tab {
131
+ readonly label: string;
132
+ readonly count?: number;
133
+ }
134
+
135
+ /** A tab strip; the active tab is bold on the selection background. */
136
+ export function tabBar(tabs: readonly Tab[], active: number, theme: Theme): string {
137
+ return tabs
138
+ .map((tab, index) => {
139
+ const count = tab.count === undefined ? "" : ` ${tab.count}`;
140
+ if (index === active) return theme.bg("selectedBg", theme.bold(` ${tab.label}${count} `));
141
+ return ` ${theme.fg("muted", tab.label)}${theme.fg("dim", count)} `;
142
+ })
143
+ .join(theme.fg("dim", "│"));
144
+ }
145
+
146
+ export interface FrameOptions {
147
+ readonly title: string;
148
+ readonly right?: string;
149
+ readonly footer?: string;
150
+ readonly borderColor?: ThemeColor;
151
+ }
152
+
153
+ /**
154
+ * A rounded overlay frame with the title set into the top border and hints into the bottom one:
155
+ * `╭─ title ─────── right ─╮` … `╰─ hints ──────────────╯`. Body lines are padded to the width.
156
+ */
157
+ export function frame(body: readonly string[], width: number, theme: Theme, options: FrameOptions): string[] {
158
+ const outer = Math.max(8, width);
159
+ const inner = outer - 4;
160
+ const border = (text: string) => theme.fg(options.borderColor ?? "borderAccent", text);
161
+ return [
162
+ borderLine("╭", "╮", options.title, options.right, outer, border),
163
+ ...body.map((line) => `${border("│")} ${fit(line, inner)} ${border("│")}`),
164
+ borderLine("╰", "╯", options.footer, undefined, outer, border),
165
+ ];
166
+ }
167
+
168
+ function borderLine(
169
+ left: string,
170
+ right: string,
171
+ label: string | undefined,
172
+ rightLabel: string | undefined,
173
+ width: number,
174
+ border: (text: string) => string,
175
+ ): string {
176
+ const room = width - 2;
177
+ const leftPart = label ? `${border("─")} ${truncateToWidth(label, Math.max(0, room - 4), "…")} ` : "";
178
+ const rightPart = rightLabel ? ` ${truncateToWidth(rightLabel, Math.max(0, room - visibleWidth(leftPart) - 4), "…")} ${border("─")}` : "";
179
+ const fill = Math.max(0, room - visibleWidth(leftPart) - visibleWidth(rightPart));
180
+ return `${border(left)}${leftPart}${border("─".repeat(fill))}${rightPart}${border(right)}`;
181
+ }
182
+
183
+ /** Overlay height: content-sized between a floor and 80% of the terminal, never more than `max`. */
184
+ export function overlayHeight(terminalRows: number, contentRows: number, options: { min?: number; max?: number } = {}): number {
185
+ const ceiling = Math.max(6, Math.min(options.max ?? 40, Math.floor(terminalRows * 0.8), terminalRows - 2));
186
+ return Math.max(Math.min(options.min ?? 10, ceiling), Math.min(ceiling, contentRows));
187
+ }
188
+
189
+ /** A one-line scroll hint shown only when content is clipped: `↑ 3 more` / `↓ 12 more`. */
190
+ export function scrollHint(above: number, below: number, theme: Theme): string | undefined {
191
+ const parts = [above > 0 ? `↑ ${above} more` : undefined, below > 0 ? `↓ ${below} more` : undefined];
192
+ return parts.some(Boolean) ? theme.fg("dim", parts.filter(Boolean).join(" ")) : undefined;
193
+ }
@@ -0,0 +1,177 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { Value } from "typebox/value";
4
+ import { ResearchReportSchema, type ResearchReport, type ResearchReportEntry, type ResearchSource } from "../research-contract.ts";
5
+ import { GLYPH, hangingWrap, joinParts } from "./kit.ts";
6
+ import {
7
+ bullets,
8
+ footerLine,
9
+ headerLine,
10
+ indent,
11
+ innerWidth,
12
+ paragraph,
13
+ plural,
14
+ runDetailRows,
15
+ type ResultFrame,
16
+ } from "./result-layout.ts";
17
+
18
+ const COLLAPSED_ANSWER_LINES = 4;
19
+ const COLLAPSED_CLAIMS = 3;
20
+ const COLLAPSED_CLAIM_LINES = 2;
21
+
22
+ export function isResearchReport(value: unknown): value is ResearchReport {
23
+ return Value.Check(ResearchReportSchema, value);
24
+ }
25
+
26
+ interface ClaimGroup {
27
+ readonly title: string;
28
+ readonly fact: string;
29
+ readonly tag: string;
30
+ readonly entries: readonly ResearchReportEntry[];
31
+ }
32
+
33
+ /** Numbers each distinct source once, in report order, so claims cite `[1][2]` and the list matches. */
34
+ class SourceIndex {
35
+ readonly sources: ResearchSource[] = [];
36
+ private readonly numbers = new Map<string, number>();
37
+
38
+ constructor(sources: readonly ResearchSource[]) {
39
+ for (const source of sources) this.number(source);
40
+ }
41
+
42
+ number(source: ResearchSource): number {
43
+ const key = source.url.trim();
44
+ const existing = this.numbers.get(key);
45
+ if (existing !== undefined) return existing;
46
+ this.sources.push(source);
47
+ this.numbers.set(key, this.sources.length);
48
+ return this.sources.length;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The research report: the answer, numbered claims with `[n]` citations, the non-empty conflicting and
54
+ * uncertain groups, and the numbered source list. Collapsed keeps the answer and the top claims.
55
+ */
56
+ export function renderResearchResult(frame: ResultFrame, report: ResearchReport): string[] {
57
+ const { expanded, theme } = frame;
58
+ const width = innerWidth(frame);
59
+ const groups: ClaimGroup[] = [
60
+ { title: "Supported claims", fact: "supported", tag: "", entries: report.supportedClaims },
61
+ { title: "Conflicting evidence", fact: "conflicting", tag: "conflicting", entries: report.conflictingEvidence },
62
+ { title: "Uncertain", fact: "uncertain", tag: "uncertain", entries: report.uncertainties },
63
+ { title: "Inferences", fact: "inferred", tag: "inference", entries: report.inferences },
64
+ ];
65
+ const sources = new SourceIndex(report.sources);
66
+ const markers = new Map<ResearchReportEntry, string>();
67
+ for (const group of groups) {
68
+ for (const entry of group.entries) markers.set(entry, citationMarkers(entry, sources, theme));
69
+ }
70
+
71
+ const claimCount = groups.reduce((sum, group) => sum + group.entries.length, 0);
72
+ const facts = [
73
+ claimCount === 0 ? theme.fg("warning", "no verified claims") : undefined,
74
+ ...groups.map((group) => (group.entries.length > 0 ? theme.fg(group.fact === "supported" ? "text" : "warning", `${group.entries.length} ${group.fact}`) : undefined)),
75
+ ];
76
+ const sourceFacts = sources.sources.length > 0 ? [theme.fg("muted", plural(sources.sources.length, "source"))] : [];
77
+ const glyph = claimCount > 0 ? theme.fg("success", GLYPH.done) : theme.fg("warning", GLYPH.warning);
78
+ const lines = [headerLine(glyph, frame, facts, sourceFacts)];
79
+ if (report.answer.trim()) {
80
+ lines.push(...indent(paragraph(report.answer, width, (line) => theme.fg("text", line), expanded ? undefined : COLLAPSED_ANSWER_LINES)));
81
+ }
82
+
83
+ if (expanded) {
84
+ for (const group of groups) {
85
+ if (group.entries.length === 0) continue;
86
+ lines.push("", ...indent([heading(group.title, group.entries.length, theme)]));
87
+ group.entries.forEach((entry, index) => lines.push(...indent(claimLines(entry, index, markers.get(entry) ?? "", width, theme, true))));
88
+ }
89
+ if (sources.sources.length > 0) {
90
+ lines.push("", ...indent([heading("Sources", sources.sources.length, theme)]));
91
+ sources.sources.forEach((source, index) => lines.push(...indent(sourceLines(source, index + 1, width, theme))));
92
+ }
93
+ for (const [title, items] of [["Limitations", report.limitations], ["Next steps", report.nextSteps]] as const) {
94
+ if (items.length === 0) continue;
95
+ lines.push("", ...indent([heading(title, undefined, theme)]));
96
+ lines.push(...indent(bullets(items).flatMap((item) => hangingWrap(theme.fg("muted", item), width, "", " "))));
97
+ }
98
+ const rows = runDetailRows(frame);
99
+ if (rows.length > 0) lines.push("", ...rows);
100
+ } else {
101
+ const featured = featuredClaims(groups);
102
+ if (featured.length > 0) {
103
+ lines.push("");
104
+ featured.slice(0, COLLAPSED_CLAIMS).forEach(({ entry, tag }, index) => {
105
+ const marker = joinParts([markers.get(entry), tag ? theme.fg("warning", tag) : undefined], theme);
106
+ lines.push(...indent(claimLines(entry, index, marker, width, theme, false)));
107
+ });
108
+ const hidden = claimCount - Math.min(COLLAPSED_CLAIMS, featured.length);
109
+ if (hidden > 0) lines.push(...indent([theme.fg("dim", `+${plural(hidden, "more claim", "more claims")}`)]));
110
+ } else if (report.nextSteps[0]) {
111
+ lines.push(...indent(hangingWrap(theme.fg("muted", report.nextSteps[0]), width, theme.fg("dim", `${GLYPH.arrow} `))));
112
+ }
113
+ }
114
+
115
+ const footer = footerLine(frame, [], { more: true });
116
+ if (footer) lines.push(footer);
117
+ return lines;
118
+ }
119
+
120
+ /** Supported claims lead; without any, the other groups stand in, each tagged with its kind. */
121
+ function featuredClaims(groups: readonly ClaimGroup[]): { readonly entry: ResearchReportEntry; readonly tag: string }[] {
122
+ const [supported, ...rest] = groups;
123
+ if (supported && supported.entries.length > 0) return supported.entries.map((entry) => ({ entry, tag: "" }));
124
+ return rest.flatMap((group) => group.entries.map((entry) => ({ entry, tag: group.tag })));
125
+ }
126
+
127
+ function citationMarkers(entry: ResearchReportEntry, sources: SourceIndex, theme: Theme): string {
128
+ const numbers = [...new Set(entry.citations.map((citation) => sources.number(citation)))].sort((a, b) => a - b);
129
+ return numbers.map((number) => theme.fg("accent", `[${number}]`)).join("");
130
+ }
131
+
132
+ function heading(title: string, count: number | undefined, theme: Theme): string {
133
+ return `${theme.bold(title)}${count === undefined ? "" : theme.fg("dim", ` ${count}`)}`;
134
+ }
135
+
136
+ /** `1. claim [1][2]` with a hanging indent; expanded adds the explanation beneath. */
137
+ function claimLines(entry: ResearchReportEntry, index: number, marker: string, width: number, theme: Theme, expanded: boolean): string[] {
138
+ const number = `${index + 1}. `;
139
+ const rest = " ".repeat(number.length);
140
+ const claim = `${theme.fg("text", entry.claim)}${marker ? ` ${marker}` : ""}`;
141
+ const lines = hangingWrap(claim, width, theme.fg("dim", number), rest);
142
+ if (!expanded) return clampLines(lines, COLLAPSED_CLAIM_LINES, width);
143
+ return [...lines, ...hangingWrap(theme.fg("muted", entry.explanation), width, rest)];
144
+ }
145
+
146
+ function clampLines(lines: string[], max: number, width: number): string[] {
147
+ if (lines.length <= max) return lines;
148
+ const kept = lines.slice(0, max);
149
+ kept[max - 1] = `${truncateToWidth(kept[max - 1] ?? "", Math.max(1, width - 1), "")}…`;
150
+ return kept;
151
+ }
152
+
153
+ /** `[1] Title · docs.example.dev · 2026-09-14`; the title is a link where the terminal supports one, else the URL follows. */
154
+ function sourceLines(source: ResearchSource, number: number, width: number, theme: Theme): string[] {
155
+ const linkable = getCapabilities().hyperlinks;
156
+ const title = theme.fg("accent", source.title.trim());
157
+ const head = theme.fg("dim", `[${number}] `);
158
+ const rest = " ".repeat(`[${number}] `.length);
159
+ const host = sourceHost(source.url);
160
+ const date = source.publishedAt?.trim();
161
+ const line = joinParts([
162
+ linkable ? hyperlink(title, source.url) : title,
163
+ host ? theme.fg("muted", host) : undefined,
164
+ date ? theme.fg("muted", date) : undefined,
165
+ ], theme);
166
+ const lines = hangingWrap(line, width, head, rest);
167
+ if (!linkable) lines.push(...hangingWrap(theme.fg("dim", source.url), width, rest));
168
+ return lines;
169
+ }
170
+
171
+ function sourceHost(url: string): string | undefined {
172
+ try {
173
+ return new URL(url).host.replace(/^www\./, "");
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
@@ -0,0 +1,209 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
+ import { isFiniteNumber, isRecord } from "../guards.ts";
4
+ import { labelledLines } from "../review/review-format.ts";
5
+ import { formatCount } from "../text.ts";
6
+ import { isWorkflowUsageSnapshot } from "../usage.ts";
7
+ import { formatCost, GLYPH, joinParts, keyHints, spread } from "./kit.ts";
8
+ import { formatDuration } from "./workflow-format.ts";
9
+
10
+ /**
11
+ * Layout shared by every workflow result screen: a status header with right-aligned run metadata,
12
+ * an indented body, labelled detail rows, and a hint footer. Everything is drawn for one width.
13
+ */
14
+
15
+ /** What the result renderers read. Persisted message details are unvalidated, so `usage` stays `unknown`. */
16
+ export interface WorkflowResultView {
17
+ readonly name: string;
18
+ readonly result: unknown;
19
+ readonly usage?: unknown;
20
+ readonly perf?: unknown;
21
+ readonly runId?: string;
22
+ readonly resumedFromRunId?: string;
23
+ readonly startedAt?: unknown;
24
+ readonly completedAt?: unknown;
25
+ /** Set on background deliveries, together with the run's final `status`. */
26
+ readonly background?: unknown;
27
+ readonly status?: unknown;
28
+ }
29
+
30
+ /** Keys the result footer advertises; a missing key hides its hint. */
31
+ export interface WorkflowResultHints {
32
+ /** Opens the code-review findings viewer: the configured shortcut, or `/workflow:results` when unbound. */
33
+ readonly triage?: string;
34
+ /** Opens the live or most recent run's inspector: the configured shortcut, or `/workflow:inspector` when unbound. */
35
+ readonly inspect?: string;
36
+ /** pi's expand-tool-output key (`app.tools.expand`). */
37
+ readonly expand?: string;
38
+ }
39
+
40
+ export interface ResultFrame {
41
+ readonly view: WorkflowResultView;
42
+ readonly expanded: boolean;
43
+ readonly width: number;
44
+ readonly theme: Theme;
45
+ readonly hints: WorkflowResultHints;
46
+ }
47
+
48
+ export type HintPair = readonly [key: string, description: string];
49
+
50
+ export const INDENT = " ";
51
+
52
+ /** Body width once the two-column indent is taken. */
53
+ export function innerWidth(frame: ResultFrame): number {
54
+ return Math.max(10, frame.width - INDENT.length);
55
+ }
56
+
57
+ /** Renders lines for the width pi offers and caches them until the width or theme changes. */
58
+ export class LinesComponent implements Component {
59
+ private cache: { readonly width: number; readonly lines: string[] } | undefined;
60
+
61
+ constructor(private readonly draw: (width: number) => string[]) {}
62
+
63
+ render(width: number): string[] {
64
+ if (this.cache?.width !== width) {
65
+ this.cache = { width, lines: this.draw(width).map((line) => truncateToWidth(line, width, "…")) };
66
+ }
67
+ return this.cache.lines;
68
+ }
69
+
70
+ invalidate(): void {
71
+ this.cache = undefined;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * `✓ code-review 3 findings · 1 high 4s · ↑34k ↓1k · $0.02`. Run metadata is right-aligned.
77
+ * When the line is too narrow, cost and tokens go first, then the optional facts, then the duration,
78
+ * so the essential facts never wrap.
79
+ */
80
+ export function headerLine(glyph: string, frame: ResultFrame, facts: readonly (string | undefined)[], optionalFacts: readonly string[] = []): string {
81
+ const { theme, width } = frame;
82
+ const leftFor = (parts: readonly (string | undefined)[]) => {
83
+ const joined = joinParts(parts, theme);
84
+ return `${glyph} ${theme.bold(frame.view.name)}${joined ? ` ${joined}` : ""}`;
85
+ };
86
+ const meta = runMeta(frame);
87
+ const variants: { readonly left: string; readonly meta: number }[] = [];
88
+ const full = leftFor([...facts, ...optionalFacts]);
89
+ for (let count = meta.length; count >= 1; count--) variants.push({ left: full, meta: count });
90
+ if (optionalFacts.length > 0) variants.push({ left: leftFor(facts), meta: Math.min(1, meta.length) });
91
+ for (const variant of variants) {
92
+ const right = joinParts(meta.slice(0, variant.meta), theme);
93
+ if (visibleWidth(variant.left) + 2 + visibleWidth(right) <= width) return spread(variant.left, right, width);
94
+ }
95
+ const left = visibleWidth(full) <= width || optionalFacts.length === 0 ? full : leftFor(facts);
96
+ return truncateToWidth(left, width, "…");
97
+ }
98
+
99
+ /** Duration, then tokens, then cost; zeros and unknowns are left out. */
100
+ function runMeta({ view, theme }: ResultFrame): string[] {
101
+ const parts: string[] = [];
102
+ const duration = runDuration(view);
103
+ if (duration !== undefined) parts.push(theme.fg("muted", formatDuration(duration)));
104
+ if (isWorkflowUsageSnapshot(view.usage) && view.usage.totals.totalTokens > 0) {
105
+ const { input, cacheRead, cacheWrite, output, cost } = view.usage.totals;
106
+ parts.push(theme.fg("muted", `↑${formatCount(input + cacheRead + cacheWrite)} ↓${formatCount(output)}`));
107
+ if (cost.total > 0) parts.push(theme.fg("muted", formatCost(cost.total)));
108
+ }
109
+ return parts;
110
+ }
111
+
112
+ function runDuration(view: WorkflowResultView): number | undefined {
113
+ if (!isFiniteNumber(view.startedAt) || !isFiniteNumber(view.completedAt)) return undefined;
114
+ const ms = view.completedAt - view.startedAt;
115
+ return ms > 0 ? ms : undefined;
116
+ }
117
+
118
+ export function agentCount(view: WorkflowResultView): number {
119
+ return isWorkflowUsageSnapshot(view.usage) ? view.usage.agents.length : 0;
120
+ }
121
+
122
+ /** `3 findings`, `1 finding`. */
123
+ export function plural(count: number, noun: string, pluralNoun = `${noun}s`): string {
124
+ return `${count} ${count === 1 ? noun : pluralNoun}`;
125
+ }
126
+
127
+ /** Wraps plain text to `width`, styling each line, and ends a clipped paragraph with `…`. */
128
+ export function paragraph(text: string, width: number, style: (line: string) => string, maxLines = Number.POSITIVE_INFINITY): string[] {
129
+ const lines = wrapTextWithAnsi(text.trim(), Math.max(8, width));
130
+ if (lines.length > maxLines) {
131
+ lines.length = maxLines;
132
+ lines[maxLines - 1] = `${truncateToWidth(lines[maxLines - 1] ?? "", Math.max(1, width - 1), "")}…`;
133
+ }
134
+ return lines.map(style);
135
+ }
136
+
137
+ /** True when `paragraph` would clip the text at `maxLines`. */
138
+ export function clips(text: string, width: number, maxLines: number): boolean {
139
+ return wrapTextWithAnsi(text.trim(), Math.max(8, width)).length > maxLines;
140
+ }
141
+
142
+ export function indent(lines: readonly string[]): string[] {
143
+ return lines.map((line) => (line ? `${INDENT}${line}` : line));
144
+ }
145
+
146
+ /** Labelled rows aligned with finding details: `Run 3cfb22c2-… · resumed from 1a2b3c4d`. */
147
+ export function labelled(frame: ResultFrame, label: string, items: readonly string[], itemIndent = ""): string[] {
148
+ if (items.length === 0) return [];
149
+ return indent(labelledLines(label, items, innerWidth(frame), frame.theme, itemIndent));
150
+ }
151
+
152
+ export function bullets(items: readonly string[]): string[] {
153
+ return items.map((item) => `${GLYPH.bullet} ${item}`);
154
+ }
155
+
156
+ /** The full run identity (with the agent count unless the header has it) and perf samples, shown only when expanded. */
157
+ export function runDetailRows(frame: ResultFrame, options: { readonly agents: boolean } = { agents: true }): string[] {
158
+ const { view, theme } = frame;
159
+ const agents = options.agents ? agentCount(view) : 0;
160
+ const run = view.runId
161
+ ? joinParts([
162
+ theme.fg("muted", view.runId),
163
+ view.resumedFromRunId ? theme.fg("muted", `resumed from ${shortRunId(view.resumedFromRunId)}`) : undefined,
164
+ view.background === true ? theme.fg("muted", "background") : undefined,
165
+ agents > 0 ? theme.fg("muted", plural(agents, "agent")) : undefined,
166
+ ], theme)
167
+ : undefined;
168
+ const perf = perfSamples(view.perf);
169
+ return [
170
+ ...(run ? labelled(frame, "Run", [run]) : []),
171
+ ...labelled(frame, "Perf", perf.length > 0 ? [theme.fg("muted", perf.join(" · "))] : []),
172
+ ];
173
+ }
174
+
175
+ function perfSamples(perf: unknown): string[] {
176
+ if (!isRecord(perf) || !Array.isArray(perf.aggregates)) return [];
177
+ return perf.aggregates
178
+ .flatMap((aggregate: unknown) => (isRecord(aggregate) && typeof aggregate.name === "string" && isFiniteNumber(aggregate.total)
179
+ ? [`${aggregate.name} ${Math.round(aggregate.total)}ms`]
180
+ : []))
181
+ .slice(0, 6);
182
+ }
183
+
184
+ export function shortRunId(runId: string): string {
185
+ return runId.slice(0, 8);
186
+ }
187
+
188
+ /**
189
+ * `ctrl+shift+r triage · ctrl+shift+m inspect · ctrl+o expand · run 3cfb22c2`. Collapsed results name the short run id;
190
+ * expanded ones show the full id in the Run row instead, so it is not repeated here.
191
+ */
192
+ export function footerLine(frame: ResultFrame, pairs: readonly HintPair[], options: { readonly more: boolean }): string | undefined {
193
+ const { view, theme, hints, expanded } = frame;
194
+ const allPairs = [...pairs];
195
+ if (hints.inspect) allPairs.push([hints.inspect, "inspect"]);
196
+ if (hints.expand && (expanded || options.more)) allPairs.push([hints.expand, expanded ? "collapse" : "expand"]);
197
+ const keys = allPairs.length > 0 ? keyHints(allPairs, theme) : undefined;
198
+ const facts = expanded ? [] : [
199
+ view.runId ? theme.fg("dim", `run ${shortRunId(view.runId)}`) : undefined,
200
+ view.background === true ? theme.fg("dim", "background") : undefined,
201
+ ];
202
+ // The run facts give way before any key hint is cut.
203
+ for (let count = facts.length; count >= 0; count--) {
204
+ const line = joinParts([keys, ...facts.slice(0, count)], theme);
205
+ if (!line) return undefined;
206
+ if (count === 0 || visibleWidth(line) <= innerWidth(frame)) return `${INDENT}${line}`;
207
+ }
208
+ return undefined;
209
+ }