@fyeeme/pi-dynamic-workflows 0.1.1 → 2.0.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/README.md +21 -8
- package/README.zh-CN.md +12 -4
- package/index.ts +139 -328
- package/package.json +17 -8
- package/prompts/implement-and-review.md +29 -0
- package/prompts/wf-review.md +30 -0
- package/sessions/spawn.ts +1 -1
- package/skills/workflow-author/SKILL.md +69 -0
- package/src/agent/dispatch.ts +4 -4
- package/src/format.ts +6 -15
- package/src/library.ts +143 -0
- package/src/runner/index.ts +2 -1
- package/src/runner/stage-executor.ts +28 -1
- package/src/types.ts +9 -12
- package/workflows/review-extension.ts +51 -0
- package/workflows/review-local-diff.ts +64 -0
- package/src/inspect.ts +0 -237
- package/src/ui-groups.ts +0 -43
package/src/inspect.ts
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WorkflowInspect — fullscreen overlay viewer with a master-detail split.
|
|
3
|
-
*
|
|
4
|
-
* Opened via `/wf-inspect` as an overlay (`ctx.ui.custom(..., { overlay: true })`)
|
|
5
|
-
* sized to ~90% × 80% of the terminal. The body is split into two panes that
|
|
6
|
-
* fill the wide overlay instead of wasting it on a single narrow column:
|
|
7
|
-
*
|
|
8
|
-
* ┌─ STEPS (left ~30%) ──────┬─ DETAIL (right ~70%) ──────────────┐
|
|
9
|
-
* │ ✓ gather · 1.2k tok │ collected 3 sources on topic X │
|
|
10
|
-
* │▸✓ fan · 8.4k tok │ [fan#1] Research alpha. │
|
|
11
|
-
* │ ○ refine · pending │ 8.4k tok · 3 agent(s) · 1240ms │
|
|
12
|
-
* └──────────────────────────┴────────────────────────────────────┘
|
|
13
|
-
*
|
|
14
|
-
* Left: compact step list (always visible, ↑↓/j/k selects, auto-scrolls).
|
|
15
|
-
* Right: the selected step's full results + stats (PgUp/PgDn/Shift+↑↓/Home/End
|
|
16
|
-
* scrolls the detail). No toggle — detail is always shown for the selection.
|
|
17
|
-
* esc/q exits.
|
|
18
|
-
*/
|
|
19
|
-
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
|
-
import type { RunResult, StepResult } from "./types.ts";
|
|
21
|
-
import { buildRenderGroups, type PhaseDef } from "./ui-groups.ts";
|
|
22
|
-
import { BOLD, CYAN, DIM, GREEN, RED, YELLOW, fmtTokens } from "./format.ts";
|
|
23
|
-
|
|
24
|
-
/** Overlay maxHeight percentage — keep in sync with the index.ts overlayOptions. */
|
|
25
|
-
const VIEWPORT_HEIGHT_PCT = 80;
|
|
26
|
-
/** header(1) + sep(1) + col-header(1) + sep(1) + [body] + sep(1) + footer(1). */
|
|
27
|
-
const CHROME_LINES = 6;
|
|
28
|
-
const MIN_VIEWPORT = 3;
|
|
29
|
-
/** Left pane share of the width (rest goes to the detail pane + separator). */
|
|
30
|
-
const LEFT_PCT = 0.3;
|
|
31
|
-
const MIN_LEFT = 16;
|
|
32
|
-
|
|
33
|
-
/** Minimal TUI surface WorkflowInspect needs. Narrower than full `TUI`. */
|
|
34
|
-
export interface InspectTUI {
|
|
35
|
-
requestRender(): void;
|
|
36
|
-
terminal: { readonly rows: number };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function statusIcon(status: StepResult["status"]): string {
|
|
40
|
-
switch (status) {
|
|
41
|
-
case "done":
|
|
42
|
-
return GREEN("✓");
|
|
43
|
-
case "failed":
|
|
44
|
-
return RED("✗");
|
|
45
|
-
case "skipped":
|
|
46
|
-
return YELLOW("⏭");
|
|
47
|
-
default:
|
|
48
|
-
return YELLOW("⏳");
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Fit a (possibly ANSI-colored) string into exactly `w` visible columns:
|
|
53
|
-
* truncate with … if too long, pad with spaces if too short. */
|
|
54
|
-
function field(s: string, w: number): string {
|
|
55
|
-
return truncateToWidth(s, w, "…", true);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export class WorkflowInspect {
|
|
59
|
-
private readonly result: RunResult;
|
|
60
|
-
private readonly tui: InspectTUI;
|
|
61
|
-
private readonly close: () => void;
|
|
62
|
-
private readonly phases?: readonly PhaseDef[];
|
|
63
|
-
private selected = 0;
|
|
64
|
-
/** Right-pane (detail) scroll offset; reset to 0 whenever selection changes. */
|
|
65
|
-
private detailScroll = 0;
|
|
66
|
-
/** Left-pane scroll offset; auto-adjusted to keep the selection visible. */
|
|
67
|
-
private leftScroll = 0;
|
|
68
|
-
/** Last known detail content height (set during render). Lets PgUp/PgDn
|
|
69
|
-
* clamp an Infinity detailScroll (set by End) before arithmetic, so
|
|
70
|
-
* End→PgUp is not swallowed by Infinity - vp = Infinity. */
|
|
71
|
-
private lastMaxDetailScroll = 0;
|
|
72
|
-
/** step index → row index within buildLeftLines() (phase title lines shift
|
|
73
|
-
* step rows past their raw array index — selection must scroll by row). */
|
|
74
|
-
private rowOfStep = new Map<number, number>();
|
|
75
|
-
|
|
76
|
-
constructor(result: RunResult, tui: InspectTUI, close: () => void, phases?: readonly PhaseDef[]) {
|
|
77
|
-
this.result = result;
|
|
78
|
-
this.tui = tui;
|
|
79
|
-
this.close = close;
|
|
80
|
-
this.phases = phases;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private viewportHeight(): number {
|
|
84
|
-
const rows = this.tui.terminal.rows > 0 ? this.tui.terminal.rows : 24;
|
|
85
|
-
return Math.max(MIN_VIEWPORT, Math.floor((rows * VIEWPORT_HEIGHT_PCT) / 100) - CHROME_LINES);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
handleInput(data: string): void {
|
|
89
|
-
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
90
|
-
this.close();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const n = this.result.steps.length;
|
|
94
|
-
if (n === 0) return;
|
|
95
|
-
const vp = this.viewportHeight();
|
|
96
|
-
// Right-pane (detail) scroll.
|
|
97
|
-
if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
98
|
-
// End may have left detailScroll as the Infinity sentinel (clamped to a
|
|
99
|
-
// finite value only on the next render) — clamp before arithmetic so
|
|
100
|
-
// Infinity - vp stays Infinity and PgUp appears dead.
|
|
101
|
-
const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
|
|
102
|
-
this.detailScroll = Math.max(0, cur - vp);
|
|
103
|
-
this.tui.requestRender();
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
107
|
-
const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
|
|
108
|
-
this.detailScroll = cur + vp;
|
|
109
|
-
this.tui.requestRender();
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
if (matchesKey(data, "home")) {
|
|
113
|
-
this.detailScroll = 0;
|
|
114
|
-
this.tui.requestRender();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (matchesKey(data, "end")) {
|
|
118
|
-
this.detailScroll = Number.POSITIVE_INFINITY;
|
|
119
|
-
this.tui.requestRender();
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
// Selection moves — reset the detail pane to the top of the new step.
|
|
123
|
-
if (matchesKey(data, "up") || data === "k") {
|
|
124
|
-
this.selected = (this.selected - 1 + n) % n;
|
|
125
|
-
this.detailScroll = 0;
|
|
126
|
-
this.tui.requestRender();
|
|
127
|
-
} else if (matchesKey(data, "down") || data === "j") {
|
|
128
|
-
this.selected = (this.selected + 1) % n;
|
|
129
|
-
this.detailScroll = 0;
|
|
130
|
-
this.tui.requestRender();
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
invalidate(): void {
|
|
135
|
-
// No cached render state.
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
render(width: number): string[] {
|
|
139
|
-
const r = this.result;
|
|
140
|
-
const w = Math.max(width, 40);
|
|
141
|
-
const leftW = Math.max(MIN_LEFT, Math.floor(w * LEFT_PCT));
|
|
142
|
-
const rightW = Math.max(20, w - leftW - 1); // -1 for the "│" separator
|
|
143
|
-
const sep = DIM("│");
|
|
144
|
-
const hr = DIM("─".repeat(w));
|
|
145
|
-
const out: string[] = [];
|
|
146
|
-
|
|
147
|
-
// --- Header (full width) ---
|
|
148
|
-
const degraded = r.degradedSteps?.length ? ` · ${r.degradedSteps.length} degraded` : "";
|
|
149
|
-
const errTag = r.errorCategory ? ` [${r.errorCategory}]` : "";
|
|
150
|
-
const head = `${BOLD(`workflow ${CYAN(r.runId)} → ${r.status}`)} ${DIM(`${r.stats.agents} agents · ${fmtTokens(r.stats.tokens)} tok · ${(r.stats.durationMs / 1000).toFixed(1)}s${degraded}`)}`;
|
|
151
|
-
out.push(field(r.error ? RED(`${head} ${r.error}${errTag}`) : head, w));
|
|
152
|
-
out.push(hr);
|
|
153
|
-
|
|
154
|
-
// --- Column header ---
|
|
155
|
-
const selStep = r.steps[this.selected];
|
|
156
|
-
const colLeft = BOLD("STEPS");
|
|
157
|
-
const colRight = DIM(`DETAIL${selStep ? ` · ${selStep.id} (${selStep.type})` : ""}`);
|
|
158
|
-
out.push(field(colLeft, leftW) + sep + field(colRight, rightW));
|
|
159
|
-
out.push(hr);
|
|
160
|
-
|
|
161
|
-
// --- Body: two panes zipped row-by-row ---
|
|
162
|
-
const vp = this.viewportHeight();
|
|
163
|
-
const leftLines = this.buildLeftLines(leftW);
|
|
164
|
-
// Keep the selected step visible in the left pane. leftLines may start
|
|
165
|
-
// with phase title rows, so the selection's ROW (not its raw step index)
|
|
166
|
-
// drives the window — otherwise phases push the selected step off-screen.
|
|
167
|
-
const selRow = this.rowOfStep.get(this.selected) ?? this.selected;
|
|
168
|
-
if (selRow < this.leftScroll) this.leftScroll = selRow;
|
|
169
|
-
else if (selRow >= this.leftScroll + vp) this.leftScroll = selRow - vp + 1;
|
|
170
|
-
this.leftScroll = Math.max(0, Math.min(this.leftScroll, Math.max(0, leftLines.length - vp)));
|
|
171
|
-
|
|
172
|
-
const rightLines = selStep ? this.buildRightLines(selStep) : [DIM("(no step)")];
|
|
173
|
-
const maxDetailScroll = Math.max(0, rightLines.length - vp);
|
|
174
|
-
this.lastMaxDetailScroll = maxDetailScroll;
|
|
175
|
-
this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxDetailScroll));
|
|
176
|
-
|
|
177
|
-
for (let i = 0; i < vp; i++) {
|
|
178
|
-
const l = field(leftLines[this.leftScroll + i] ?? "", leftW);
|
|
179
|
-
const rr = field(rightLines[this.detailScroll + i] ?? "", rightW);
|
|
180
|
-
out.push(l + sep + rr);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// --- Footer ---
|
|
184
|
-
out.push(hr);
|
|
185
|
-
const footL = DIM("↑↓/j/k select · PgUp/PgDn/Shift+↑↓ scroll detail · Home/End · esc exit");
|
|
186
|
-
const dPct = rightLines.length <= vp ? "all" : `${Math.round(((this.detailScroll + vp) / rightLines.length) * 100)}%`;
|
|
187
|
-
const footR = DIM(`${this.detailScroll}/${rightLines.length} (${dPct}) · ${this.selected + 1}/${r.steps.length} steps`);
|
|
188
|
-
out.push(field(footL, leftW) + sep + field(footR, rightW));
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Left pane: one compact line per step (status · id · type · tokens). */
|
|
193
|
-
private buildLeftLines(w: number): string[] {
|
|
194
|
-
const idxOf = new Map(this.result.steps.map((s, i) => [s.id, i]));
|
|
195
|
-
const groups = buildRenderGroups(this.result.steps, (s) => s.id, this.phases);
|
|
196
|
-
const lines: string[] = [];
|
|
197
|
-
const rowOf = new Map<number, number>();
|
|
198
|
-
// Every phase group renders its header (a phase interrupted by ungrouped
|
|
199
|
-
// items produces two groups; deduplicating the second header would leave
|
|
200
|
-
// an indented, header-less orphan row).
|
|
201
|
-
for (const g of groups) {
|
|
202
|
-
if (g.kind === "phase" && g.title) {
|
|
203
|
-
lines.push(field(BOLD(g.title), w));
|
|
204
|
-
}
|
|
205
|
-
for (const s of g.items) {
|
|
206
|
-
const i = idxOf.get(s.id) ?? 0;
|
|
207
|
-
rowOf.set(i, lines.length);
|
|
208
|
-
const sel = i === this.selected;
|
|
209
|
-
const tok = s.stats.tokens > 0 ? ` · ${fmtTokens(s.stats.tokens)} tok` : "";
|
|
210
|
-
const body = `${statusIcon(s.status)} ${s.id} ${DIM(`(${s.type})${tok}`)}`;
|
|
211
|
-
lines.push(field(sel ? `${CYAN("▸")}${BOLD(body)}` : ` ${body}`, w));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
this.rowOfStep = rowOf;
|
|
215
|
-
return lines;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Right pane: the selected step's full results + a stats footer line.
|
|
219
|
-
* `results === undefined` means a live snapshot with nothing settled yet —
|
|
220
|
-
* show an honest "in progress" marker instead of JSON.stringify(undefined). */
|
|
221
|
-
private buildRightLines(s: StepResult): string[] {
|
|
222
|
-
const lines: string[] = [];
|
|
223
|
-
if (s.results === undefined) {
|
|
224
|
-
lines.push(DIM("(in progress — no agent output settled yet)"));
|
|
225
|
-
} else {
|
|
226
|
-
const body = typeof s.results === "string" ? s.results : JSON.stringify(s.results, null, 2);
|
|
227
|
-
for (const ln of body.split("\n")) lines.push(DIM(ln));
|
|
228
|
-
}
|
|
229
|
-
lines.push("");
|
|
230
|
-
lines.push(DIM(`${fmtTokens(s.stats.tokens)} tok · ${s.stats.agents} agent(s) · ${s.stats.durationMs}ms · ${s.stats.failures} fail`));
|
|
231
|
-
return lines;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
dispose(): void {
|
|
235
|
-
// Nothing to release.
|
|
236
|
-
}
|
|
237
|
-
}
|
package/src/ui-groups.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared phase-grouping logic (C1+D1) — used by the live progress widget
|
|
3
|
-
* (index.ts) and the `/wf-inspect` view (src/inspect.ts) so both render with
|
|
4
|
-
* identical grouping. Extracted as a pure function so it is unit-testable.
|
|
5
|
-
*
|
|
6
|
-
* Declared phases carry a header; ungrouped items collect into a header-less
|
|
7
|
-
* default group interleaved at declaration order. When no phases are declared
|
|
8
|
-
* (or none match), the whole item list is one default group (flat render).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface PhaseDef {
|
|
12
|
-
readonly title: string;
|
|
13
|
-
readonly detail?: string;
|
|
14
|
-
readonly stepIds: readonly string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RenderGroup<T> {
|
|
18
|
-
readonly kind: "phase" | "default";
|
|
19
|
-
readonly title?: string;
|
|
20
|
-
readonly detail?: string;
|
|
21
|
-
readonly items: T[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function buildRenderGroups<T>(
|
|
25
|
-
items: readonly T[],
|
|
26
|
-
getId: (item: T) => string,
|
|
27
|
-
phases?: readonly PhaseDef[],
|
|
28
|
-
): RenderGroup<T>[] {
|
|
29
|
-
const stepPhase = new Map<string, { title: string; detail?: string }>();
|
|
30
|
-
if (phases && phases.length > 0) {
|
|
31
|
-
for (const ph of phases) for (const sid of ph.stepIds) stepPhase.set(sid, { title: ph.title, detail: ph.detail });
|
|
32
|
-
}
|
|
33
|
-
const groups: RenderGroup<T>[] = [];
|
|
34
|
-
for (const item of items) {
|
|
35
|
-
const ph = stepPhase.get(getId(item));
|
|
36
|
-
const key = ph ? `phase:${ph.title}` : "default";
|
|
37
|
-
const last = groups[groups.length - 1];
|
|
38
|
-
const lastKey = last ? (last.kind === "phase" ? `phase:${last.title}` : "default") : "";
|
|
39
|
-
if (last && lastKey === key) last.items.push(item);
|
|
40
|
-
else groups.push(ph ? { kind: "phase", title: ph.title, detail: ph.detail, items: [item] } : { kind: "default", items: [item] });
|
|
41
|
-
}
|
|
42
|
-
return groups;
|
|
43
|
-
}
|