@ghyper9023/pi-dev-workflow 0.6.2 → 0.7.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.
- package/.github/workflows/release.yml +56 -0
- package/.pre-commit-config.yaml +75 -0
- package/.version/RELEASE-v0.7.0.md +85 -0
- package/README.md +80 -228
- package/extensions/dev-prompts.ts +351 -450
- package/extensions/git-commands.ts +62 -171
- package/extensions/grill-me-agent.ts +98 -137
- package/extensions/session-utils.ts +169 -0
- package/extensions/ui-helpers.ts +2 -780
- package/package.json +2 -2
- package/prompts/APPEND_SYSTEM.md +63 -59
- package/skills/review-html/SKILL.md +1 -1
- package/tests/test-no-subagents.mjs +146 -0
- package/.doc/AGENT-FRONTMATTER-REFERENCE.md +0 -198
- package/agents/git-agent.md +0 -44
- package/agents/grill/dev-doc-grill-agent.md +0 -42
- package/agents/grill/dev-fix-grill-agent.md +0 -44
- package/agents/grill/dev-grill-agent.md +0 -40
- package/agents/grill/dev-perf-grill-agent.md +0 -45
- package/agents/grill/dev-prd-agent.md +0 -60
- package/agents/grill/dev-refactor-grill-agent.md +0 -46
- package/agents/grill/dev-test-grill-agent.md +0 -45
- package/agents/review-agent.md +0 -53
- package/agents/workflow/docWriter-agent.md +0 -53
- package/agents/workflow/planner-agent.md +0 -131
- package/agents/workflow/reviewer-agent.md +0 -128
- package/agents/workflow/trimmer-agent.md +0 -78
- package/agents/workflow/worker-agent.md +0 -70
- package/extensions/sub-agents.ts +0 -954
- package/extensions/workflow-engine.ts +0 -2005
- package/tests/test-grill-json-fix.mjs +0 -243
- package/tests/test-loopcount-timeout-fix.mjs +0 -336
- package/tests/test-output-directory-structure.mjs +0 -177
- package/tests/test-save-answer-file-workflow.mjs +0 -187
- package/tests/test-workflow-config.mjs +0 -244
- package/tests/test-workflow-engine-bugs.mjs +0 -908
- package/tests/test-workflow-engine.mjs +0 -518
package/extensions/ui-helpers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ui-helpers.ts — Rich TUI component builders for select/confirm/input
|
|
2
|
+
* ui-helpers.ts — Rich TUI component builders for select/confirm/input
|
|
3
3
|
*
|
|
4
4
|
* Wraps ctx.ui.custom() with proper text wrapping, black-background panels,
|
|
5
5
|
* and Ctrl+O expand/collapse support.
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
* - uiSelect() — replaces ctx.ui.select() with wrapping
|
|
9
9
|
* - uiConfirm() — replaces ctx.ui.confirm() with wrapping
|
|
10
10
|
* - uiInput() — replaces ctx.ui.input() with wrapping
|
|
11
|
-
* - updateWorkflowWidget — persistent progress panel (widget)
|
|
12
|
-
* - sendWorkflowResult() — persistent session completion message
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -31,9 +29,6 @@ import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
|
31
29
|
// ── Types ────────────────────────────────────────────────────
|
|
32
30
|
|
|
33
31
|
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
34
|
-
type TUI = Parameters<Parameters<ExtensionCommandContext["ui"]["custom"]>[0]>[0];
|
|
35
|
-
|
|
36
|
-
const WIDGET_KEY = "dev-workflow";
|
|
37
32
|
|
|
38
33
|
// ── Constants ─────────────────────────────────────────────────
|
|
39
34
|
|
|
@@ -299,783 +294,10 @@ export function uiInput(
|
|
|
299
294
|
});
|
|
300
295
|
}
|
|
301
296
|
|
|
302
|
-
// ═══════════════════════════════════════════════════════════════
|
|
303
|
-
// Workflow Progress Widget
|
|
304
|
-
// ═══════════════════════════════════════════════════════════════
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* A single sub-step within a workflow step (e.g. planner, worker, reviewer).
|
|
308
|
-
*/
|
|
309
|
-
export interface WorkflowSubStepWidgetState {
|
|
310
|
-
agent: string;
|
|
311
|
-
status: "pending" | "running" | "done" | "failed";
|
|
312
|
-
/** Thinking level for this agent ("off" | "low" | "medium" | "high" | "xhigh") */
|
|
313
|
-
thinkingLevel?: string;
|
|
314
|
-
/** Recent tool activity (e.g. "edit:src/main.rs", "read:config.json") */
|
|
315
|
-
tools?: string[];
|
|
316
|
-
/** Output file paths */
|
|
317
|
-
outputs?: string[];
|
|
318
|
-
/** Free-form detail text (e.g. "3 files changed") */
|
|
319
|
-
detail?: string;
|
|
320
|
-
/** Elapsed time for this sub-step */
|
|
321
|
-
durationMs?: number;
|
|
322
|
-
/** Token usage */
|
|
323
|
-
tokenCount?: number;
|
|
324
|
-
/** Tool usage count */
|
|
325
|
-
toolCount?: number;
|
|
326
|
-
/** When this sub-step started (for live timing) */
|
|
327
|
-
startedAt?: number;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Workflow step state for the widget.
|
|
332
|
-
*/
|
|
333
|
-
export interface WorkflowStepWidgetState {
|
|
334
|
-
label: string;
|
|
335
|
-
status: "pending" | "running" | "done" | "failed" | "skipped";
|
|
336
|
-
/** Timeout in ms for this step */
|
|
337
|
-
timeoutMs?: number;
|
|
338
|
-
durationMs?: number;
|
|
339
|
-
/** When this step started executing (for live timing) */
|
|
340
|
-
startedAt?: number;
|
|
341
|
-
loopCount?: number;
|
|
342
|
-
maxLoops?: number;
|
|
343
|
-
error?: string;
|
|
344
|
-
/** Sub-steps within this step */
|
|
345
|
-
subSteps?: WorkflowSubStepWidgetState[];
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Workflow widget state shared between the workflow engine and the widget.
|
|
350
|
-
*/
|
|
351
|
-
export interface WorkflowWidgetState {
|
|
352
|
-
mode: string;
|
|
353
|
-
steps: WorkflowStepWidgetState[];
|
|
354
|
-
currentStepIndex: number;
|
|
355
|
-
startedAt: number;
|
|
356
|
-
status: "running" | "done" | "failed" | "cancelled";
|
|
357
|
-
toolCount?: number;
|
|
358
|
-
tokenCount?: number;
|
|
359
|
-
updatedAt: string;
|
|
360
|
-
/** Human-readable task summary shown in widget header, e.g. "[feat - 在 auth 中实现登录]" */
|
|
361
|
-
taskSummary?: string;
|
|
362
|
-
/** Workflow UUID for cross-file traceability */
|
|
363
|
-
workflowId?: string;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ── Widget component builder ─────────────────────────────────
|
|
367
|
-
|
|
368
|
-
let _widgetState: WorkflowWidgetState | null = null;
|
|
369
|
-
let _widgetAnimationTimer: ReturnType<typeof setInterval> | null = null;
|
|
370
|
-
let _lastWidgetCtx: ExtensionCommandContext | null = null;
|
|
371
|
-
|
|
372
|
-
/** Tracks pi's tools panel expanded state (Ctrl+O toggles it, widget mirrors it) */
|
|
373
|
-
let _widgetExpanded = false;
|
|
374
|
-
|
|
375
|
-
/** Callback invoked when user presses Esc to cancel */
|
|
376
|
-
let _onCancelWorkflow: (() => void) | null = null;
|
|
377
|
-
|
|
378
|
-
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
379
|
-
const ANIMATION_MS = 80;
|
|
380
|
-
|
|
381
|
-
function spinnerFrame(): string {
|
|
382
|
-
return SPINNER[Math.floor(Date.now() / ANIMATION_MS) % SPINNER.length]!;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function formatDurationFull(ms: number): string {
|
|
386
|
-
if (ms < 1000) return `${ms}ms`;
|
|
387
|
-
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
388
|
-
const m = Math.floor(ms / 60000);
|
|
389
|
-
const s = Math.floor((ms % 60000) / 1000);
|
|
390
|
-
return `${m}m${s}s`;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export function formatTimeout(ms: number): string {
|
|
394
|
-
const m = Math.floor(ms / 60000);
|
|
395
|
-
const s = Math.floor((ms % 60000) / 1000);
|
|
396
|
-
return s > 0 ? `${m}m${s}s` : `${m}m`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Build the widget component lines for the given state.
|
|
401
|
-
* New UI design — black background, |__ tree connectors, gold footer.
|
|
402
|
-
*
|
|
403
|
-
* Running state example:
|
|
404
|
-
* ⠋ 工作流 · 值守模式 · 6m16s
|
|
405
|
-
* ✓ 📋生成实施计划 (1m59s/超时时间15m)
|
|
406
|
-
* |__ planner ·
|
|
407
|
-
* |__ output:.pi-dev-output/pi-plans/20260520-1628-export-kcp-public2-api.md
|
|
408
|
-
* ▶ ⠋ 🔧实施代码 → 审查 · 第 1 次循环 (1s/超时时间15m)
|
|
409
|
-
* |__ worker ·
|
|
410
|
-
* | edit:代码xxx.rs
|
|
411
|
-
* | edit:代码xxx.rs
|
|
412
|
-
* |__ new:代码xxx.ts
|
|
413
|
-
* |__ reviewer ·
|
|
414
|
-
* | output:.pi-dev-output/pi-review/md/review-20260520-180001.md
|
|
415
|
-
* |__ output:.pi-dev-output/pi-review/md/review-20260520-180002.md
|
|
416
|
-
* ◦ ✂️ 精简代码 → 审查 · 第 0 次循环
|
|
417
|
-
* |__ trimmer ·
|
|
418
|
-
* |__ 正在排队
|
|
419
|
-
* |__ reviewer ·
|
|
420
|
-
* |__ 正在排队
|
|
421
|
-
* ◦ 📝 更新文档
|
|
422
|
-
* |__ docWriter ·
|
|
423
|
-
* |__ 正在排队
|
|
424
|
-
* Ctrl+O 展开详情(金色) | Escape 取消(金色)
|
|
425
|
-
*/
|
|
426
|
-
function buildWidgetLines(state: WorkflowWidgetState, theme: Theme, expanded: boolean, width: number): string[] {
|
|
427
|
-
const lines: string[] = [];
|
|
428
|
-
const elapsed = Date.now() - state.startedAt;
|
|
429
|
-
|
|
430
|
-
// ── Task summary (shown above the main header) ──
|
|
431
|
-
if (state.taskSummary) {
|
|
432
|
-
lines.push(` ${dim(theme, state.taskSummary)}`);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// ── Header ──
|
|
436
|
-
const modeLabel =
|
|
437
|
-
state.mode === "full-auto" ? "全自动模式" : state.mode === "full-attended" ? "完全值守模式" : "值守模式";
|
|
438
|
-
const glyph =
|
|
439
|
-
state.status === "running"
|
|
440
|
-
? theme.fg("accent", spinnerFrame())
|
|
441
|
-
: state.status === "done"
|
|
442
|
-
? theme.fg("success", "✓")
|
|
443
|
-
: state.status === "failed"
|
|
444
|
-
? theme.fg("error", "✗")
|
|
445
|
-
: theme.fg("warning", "■");
|
|
446
|
-
lines.push(`${glyph} 工作流 · ${dim(theme, modeLabel)} · ${dim(theme, formatDurationFull(elapsed))}`);
|
|
447
|
-
if (state.workflowId) {
|
|
448
|
-
lines.push(` ${dim(theme, `UUID: ${state.workflowId}`)}`);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ── Step list ──
|
|
452
|
-
for (let i = 0; i < state.steps.length; i++) {
|
|
453
|
-
const s = state.steps[i]!;
|
|
454
|
-
const isCurrent = i === state.currentStepIndex && state.status === "running";
|
|
455
|
-
const isDone = s.status === "done";
|
|
456
|
-
const isFailed = s.status === "failed";
|
|
457
|
-
const isRunning = s.status === "running" || isCurrent;
|
|
458
|
-
const isPending = s.status === "pending" && !isRunning;
|
|
459
|
-
const isSkipped = s.status === "skipped";
|
|
460
|
-
|
|
461
|
-
// ── Icon ──
|
|
462
|
-
// ✓ green for done, ▶ ⠋ orange for current, ◦ dim for pending
|
|
463
|
-
let icon: string;
|
|
464
|
-
if (isDone) {
|
|
465
|
-
icon = theme.fg("success", "✓");
|
|
466
|
-
} else if (isRunning) {
|
|
467
|
-
icon = `▶ ${theme.fg("warning", spinnerFrame())}`;
|
|
468
|
-
} else if (isFailed) {
|
|
469
|
-
icon = theme.fg("error", "✗");
|
|
470
|
-
} else if (isSkipped) {
|
|
471
|
-
icon = theme.fg("warning", "⏭");
|
|
472
|
-
} else {
|
|
473
|
-
icon = dim(theme, "◦");
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// ── Loop count (第 N 次循环) for loop-group steps ──
|
|
477
|
-
let loopStr = "";
|
|
478
|
-
if (s.loopCount != null && s.loopCount > 0) {
|
|
479
|
-
loopStr = dim(theme, ` · 第 ${s.loopCount} 次循环`);
|
|
480
|
-
} else if (s.maxLoops != null) {
|
|
481
|
-
if (isRunning) {
|
|
482
|
-
// 当 loop-group 开始运行时,loopCount 已经通过 executeLoopGroup 在循环开头设置了,
|
|
483
|
-
// 所以不需要 fallback 显示"第 1 次循环"
|
|
484
|
-
// 直接使用 s.loopCount 的值
|
|
485
|
-
if (s.loopCount == null || s.loopCount === 0) {
|
|
486
|
-
// 安全 fallback(理论上不会走到这里)
|
|
487
|
-
loopStr = dim(theme, ` · 第 1 次循环`);
|
|
488
|
-
}
|
|
489
|
-
} else if (isPending) {
|
|
490
|
-
loopStr = dim(theme, ` · 第 0 次循环`);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ── Label color ──
|
|
495
|
-
const labelStyle = isRunning
|
|
496
|
-
? theme.fg("warning", s.label)
|
|
497
|
-
: isDone
|
|
498
|
-
? theme.fg("success", s.label)
|
|
499
|
-
: isFailed
|
|
500
|
-
? theme.fg("error", s.label)
|
|
501
|
-
: dim(theme, s.label);
|
|
502
|
-
|
|
503
|
-
// ── Step indentation ──
|
|
504
|
-
// Done: " ✓ ..."
|
|
505
|
-
// Running: " ▶ ⠋ ..."
|
|
506
|
-
// Other: " ◦ ..."
|
|
507
|
-
let stepIndent: string;
|
|
508
|
-
if (isDone) {
|
|
509
|
-
stepIndent = " ";
|
|
510
|
-
} else if (isRunning) {
|
|
511
|
-
stepIndent = " ";
|
|
512
|
-
} else {
|
|
513
|
-
stepIndent = " ";
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ── Step line (注意:计时/超时信息只在子代理行展示,不在父步骤行展示) ──
|
|
517
|
-
lines.push(`${stepIndent}${icon} ${labelStyle}${loopStr}`);
|
|
518
|
-
|
|
519
|
-
// ── Sub-steps (agents with |__ tree) ──
|
|
520
|
-
if (s.subSteps && s.subSteps.length > 0) {
|
|
521
|
-
// Agent indent: 9 for done, 8 for running/pending
|
|
522
|
-
const agentIndent = isDone ? " " : " ";
|
|
523
|
-
// Child indent: always 12
|
|
524
|
-
const childIndent = " ";
|
|
525
|
-
const lastSubIdx = s.subSteps.length - 1;
|
|
526
|
-
|
|
527
|
-
for (let si = 0; si < s.subSteps.length; si++) {
|
|
528
|
-
const sub = s.subSteps[si]!;
|
|
529
|
-
const isSubDone = sub.status === "done" || sub.status === "failed";
|
|
530
|
-
const isSubRunning = sub.status === "running";
|
|
531
|
-
const isSubPending = sub.status === "pending";
|
|
532
|
-
const isLastSub = si === lastSubIdx;
|
|
533
|
-
|
|
534
|
-
// Sub-step icon
|
|
535
|
-
const subIcon = isSubDone
|
|
536
|
-
? theme.fg("success", "✓")
|
|
537
|
-
: isSubRunning
|
|
538
|
-
? theme.fg("accent", spinnerFrame())
|
|
539
|
-
: dim(theme, "◦");
|
|
540
|
-
|
|
541
|
-
// Agent line: " |__ ✓ worker · (52.6s/超时时间60m)"
|
|
542
|
-
// Build inline duration and timeout info for sub-step
|
|
543
|
-
let subDurStr = "";
|
|
544
|
-
let subTimeoutStr = "";
|
|
545
|
-
let subDurClose = "";
|
|
546
|
-
// ⭐ 修复:已完成/失败的子代理使用记录的 durationMs,运行中使用 live 计时
|
|
547
|
-
let elapsedMs: number | undefined;
|
|
548
|
-
if (isSubDone || sub.status === "failed") {
|
|
549
|
-
// 已完成/失败 → 使用最终记录的 durationMs(代理完成时已冻结)
|
|
550
|
-
elapsedMs = sub.durationMs;
|
|
551
|
-
} else if (isSubRunning && sub.startedAt) {
|
|
552
|
-
// 运行中 → 实时计算从 startedAt 到现在的时长
|
|
553
|
-
elapsedMs = Date.now() - sub.startedAt;
|
|
554
|
-
}
|
|
555
|
-
if (elapsedMs != null) {
|
|
556
|
-
subDurStr = dim(theme, ` (${formatDurationFull(elapsedMs)}`);
|
|
557
|
-
} else if (isSubRunning) {
|
|
558
|
-
subDurStr = dim(theme, ` (0s`);
|
|
559
|
-
}
|
|
560
|
-
// Extract timeout info from sub.detail (e.g. "超时时间60m")
|
|
561
|
-
// detail is set in runAgentWithProgress as `超时时间${formatTimeout(timeoutMs)}`
|
|
562
|
-
if (sub.detail && sub.detail.includes("超时时间")) {
|
|
563
|
-
subTimeoutStr = dim(theme, `/${sub.detail}`);
|
|
564
|
-
}
|
|
565
|
-
if (subDurStr) {
|
|
566
|
-
subDurClose = dim(theme, ")");
|
|
567
|
-
}
|
|
568
|
-
const agentConnector = dim(theme, "|__");
|
|
569
|
-
const thinkingTag = sub.thinkingLevel ? dim(theme, `thinking-${sub.thinkingLevel} `) : "";
|
|
570
|
-
lines.push(`${agentIndent}${agentConnector} ${subIcon} ${sub.agent} · ${thinkingTag}${subDurStr}${subTimeoutStr}${subDurClose}`);
|
|
571
|
-
|
|
572
|
-
// ── Children (tools, outputs, or "正在排队") ──
|
|
573
|
-
const childItems: string[] = [];
|
|
574
|
-
|
|
575
|
-
if (isSubPending) {
|
|
576
|
-
childItems.push(dim(theme, "正在排队"));
|
|
577
|
-
} else if (isSubRunning || isSubDone) {
|
|
578
|
-
if (sub.tools && sub.tools.length > 0) {
|
|
579
|
-
for (const t of sub.tools) {
|
|
580
|
-
childItems.push(t);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
if (sub.outputs && sub.outputs.length > 0) {
|
|
584
|
-
for (const o of sub.outputs) {
|
|
585
|
-
childItems.push(`output:${o}`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
if (childItems.length === 0 && sub.detail && !sub.detail.includes("超时时间")) {
|
|
589
|
-
childItems.push(sub.detail);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Render children with tree branching
|
|
594
|
-
// Non-last child: | (pipe + 3 spaces)
|
|
595
|
-
// Last child: |__ (pipe + 2 underscores + space) — closes the branch
|
|
596
|
-
const lastChildIdx = childItems.length - 1;
|
|
597
|
-
for (let ci = 0; ci < childItems.length; ci++) {
|
|
598
|
-
const isLastChild = ci === lastChildIdx;
|
|
599
|
-
const childConnector = isLastChild ? dim(theme, "|__") : dim(theme, "| ");
|
|
600
|
-
lines.push(`${childIndent}${childConnector} ${childItems[ci]!}`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
} else if (isPending) {
|
|
604
|
-
// Pending step with no sub-steps yet
|
|
605
|
-
const agentIndent = " ";
|
|
606
|
-
lines.push(`${agentIndent}${dim(theme, "|__")} ${dim(theme, "◦")} 正在排队`);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Error detail for failed steps
|
|
610
|
-
if (isFailed && s.error) {
|
|
611
|
-
for (const errLine of s.error.split("\n")) {
|
|
612
|
-
lines.push(` ${theme.fg("error", errLine)}`);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// ── Stats line ──
|
|
618
|
-
const stats: string[] = [];
|
|
619
|
-
if (state.toolCount) stats.push(`${state.toolCount} tools`);
|
|
620
|
-
if (state.tokenCount) stats.push(`${state.tokenCount} tokens`);
|
|
621
|
-
if (stats.length > 0) {
|
|
622
|
-
lines.push(` ${dim(theme, stats.join(" · "))}`);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// ── Footer hints (金色) ──
|
|
626
|
-
// if (state.status === "running") {
|
|
627
|
-
// const gold = (text: string) => theme.fg("warning", text);
|
|
628
|
-
// lines.push(` ${gold("Ctrl+O 展开详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
|
|
629
|
-
// }
|
|
630
|
-
if (state.status === "running") {
|
|
631
|
-
const gold = (text: string) => theme.fg("warning", text);
|
|
632
|
-
if (!expanded) {
|
|
633
|
-
lines.push(` ${gold("Ctrl+O 展开详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
|
|
634
|
-
} else {
|
|
635
|
-
lines.push(` ${dim(theme, "Ctrl+O 折叠详情")} ${dim(theme, "|")} ${gold("Escape 取消")}`);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
return lines;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Build the widget component factory for the current state.
|
|
644
|
-
* Returns a factory function (tui, theme) => Component.
|
|
645
|
-
* Ctrl+O toggling is handled by the animation loop, which detects
|
|
646
|
-
* changes in getToolsExpanded() and toggles _widgetExpanded independently.
|
|
647
|
-
*/
|
|
648
|
-
function buildWidgetFactory(state: WorkflowWidgetState, expanded: boolean): (_tui: unknown, theme: Theme) => Component {
|
|
649
|
-
return (_tui, theme) => {
|
|
650
|
-
const width = process.stdout.columns || 120;
|
|
651
|
-
const lines = buildWidgetLines(state, theme, expanded, width);
|
|
652
|
-
|
|
653
|
-
const container = new Container();
|
|
654
|
-
const box = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
|
655
|
-
const inner = new Container();
|
|
656
|
-
for (const line of lines) {
|
|
657
|
-
inner.addChild(new Text(` ${line}`, 1, 0));
|
|
658
|
-
}
|
|
659
|
-
box.addChild(inner);
|
|
660
|
-
container.addChild(box);
|
|
661
|
-
return container;
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Initialize or update the workflow widget.
|
|
667
|
-
* If state is provided, creates/updates the widget.
|
|
668
|
-
* If state is null, removes the widget.
|
|
669
|
-
*/
|
|
670
|
-
export function updateWorkflowWidget(ctx: ExtensionCommandContext, state: WorkflowWidgetState | null): void {
|
|
671
|
-
if (!ctx.hasUI) return;
|
|
672
|
-
|
|
673
|
-
if (!state) {
|
|
674
|
-
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
675
|
-
stopWidgetAnimation();
|
|
676
|
-
_widgetState = null;
|
|
677
|
-
_lastWidgetCtx = null;
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
_widgetState = state;
|
|
682
|
-
_lastWidgetCtx = ctx;
|
|
683
|
-
|
|
684
|
-
// Initialize expanded state from tools panel state
|
|
685
|
-
_widgetExpanded = ctx.ui.getToolsExpanded?.() ?? false;
|
|
686
|
-
ctx.ui.setWidget(WIDGET_KEY, buildWidgetFactory(state, _widgetExpanded));
|
|
687
|
-
|
|
688
|
-
if (state.status === "running") {
|
|
689
|
-
startWidgetAnimation();
|
|
690
|
-
} else {
|
|
691
|
-
stopWidgetAnimation();
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function startWidgetAnimation(): void {
|
|
696
|
-
if (_widgetAnimationTimer) return;
|
|
697
|
-
_widgetAnimationTimer = setInterval(() => {
|
|
698
|
-
if (!_widgetState || !_lastWidgetCtx?.hasUI) {
|
|
699
|
-
stopWidgetAnimation();
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
try {
|
|
703
|
-
// Sync widget's expanded state from pi's tools panel state (Ctrl+O toggles this)
|
|
704
|
-
// No fighting — let pi handle the toggle normally
|
|
705
|
-
const toolsExpanded = _lastWidgetCtx.ui.getToolsExpanded?.() ?? false;
|
|
706
|
-
_widgetExpanded = toolsExpanded;
|
|
707
|
-
|
|
708
|
-
_lastWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetFactory(_widgetState, _widgetExpanded));
|
|
709
|
-
_lastWidgetCtx.ui.requestRender?.();
|
|
710
|
-
} catch {
|
|
711
|
-
stopWidgetAnimation();
|
|
712
|
-
}
|
|
713
|
-
}, ANIMATION_MS);
|
|
714
|
-
_widgetAnimationTimer.unref?.();
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function stopWidgetAnimation(): void {
|
|
718
|
-
if (_widgetAnimationTimer) {
|
|
719
|
-
clearInterval(_widgetAnimationTimer);
|
|
720
|
-
_widgetAnimationTimer = null;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** Register a cancel callback triggered by Esc in the widget */
|
|
725
|
-
export function setWorkflowCancelCallback(fn: (() => void) | null): void {
|
|
726
|
-
_onCancelWorkflow = fn;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/** Trigger workflow cancellation */
|
|
730
|
-
export function cancelWorkflow(): void {
|
|
731
|
-
_onCancelWorkflow?.();
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ── Send workflow result to session ──────────────────────────
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Helper: extract all file changes from step states for the completion report.
|
|
738
|
-
* Returns { edits, news, deletes } and a directory-tree formatted string.
|
|
739
|
-
*
|
|
740
|
-
* Checks:
|
|
741
|
-
* 1. sub.tools for edit:/new:/delete: patterns
|
|
742
|
-
* 2. sub.outputs for output file paths (plans, reviews, etc.)
|
|
743
|
-
* 3. Falls back to known output patterns if nothing found
|
|
744
|
-
*/
|
|
745
|
-
function extractFileChanges(steps: WorkflowStepWidgetState[]): {
|
|
746
|
-
edits: number;
|
|
747
|
-
news: number;
|
|
748
|
-
deletes: number;
|
|
749
|
-
treeText: string;
|
|
750
|
-
} {
|
|
751
|
-
const editFiles: string[] = [];
|
|
752
|
-
const newFiles: string[] = [];
|
|
753
|
-
const delFiles: string[] = [];
|
|
754
|
-
|
|
755
|
-
// Collect output paths that should be tracked as files
|
|
756
|
-
const outputFiles: string[] = [];
|
|
757
|
-
|
|
758
|
-
for (const s of steps) {
|
|
759
|
-
if (!s.subSteps) continue;
|
|
760
|
-
for (const sub of s.subSteps) {
|
|
761
|
-
// Check tools for file change patterns.
|
|
762
|
-
// Supports both old format ("edit: path", "new: path", "delete: path")
|
|
763
|
-
// and new git-format ("M path", "A path", "D path") from updateToolsFromGit.
|
|
764
|
-
if (sub.tools) {
|
|
765
|
-
for (const tool of sub.tools) {
|
|
766
|
-
// Old format: "edit: path", "new: path", "delete: path"
|
|
767
|
-
const oldEdit = tool.match(/^edit:\s*(.+)/i);
|
|
768
|
-
const oldNew = tool.match(/^new:\s*(.+)/i);
|
|
769
|
-
const oldDel = tool.match(/^delete:\s*(.+)/i);
|
|
770
|
-
// New git-format: "M path", "A path", "D path"
|
|
771
|
-
const gitFormat = tool.match(/^([MAD])\s{2,}(.+)$/);
|
|
772
|
-
|
|
773
|
-
if (oldEdit) {
|
|
774
|
-
const fp = oldEdit[1]!.trim();
|
|
775
|
-
if (!editFiles.includes(fp)) editFiles.push(fp);
|
|
776
|
-
} else if (oldNew) {
|
|
777
|
-
const fp = oldNew[1]!.trim();
|
|
778
|
-
if (!newFiles.includes(fp)) newFiles.push(fp);
|
|
779
|
-
} else if (oldDel) {
|
|
780
|
-
const fp = oldDel[1]!.trim();
|
|
781
|
-
if (!delFiles.includes(fp)) delFiles.push(fp);
|
|
782
|
-
} else if (gitFormat) {
|
|
783
|
-
const status = gitFormat[1]!;
|
|
784
|
-
const fp = gitFormat[2]!.trim();
|
|
785
|
-
if (status === "M" && !editFiles.includes(fp)) {
|
|
786
|
-
editFiles.push(fp);
|
|
787
|
-
} else if (status === "A" && !newFiles.includes(fp)) {
|
|
788
|
-
newFiles.push(fp);
|
|
789
|
-
} else if (status === "D" && !delFiles.includes(fp)) {
|
|
790
|
-
delFiles.push(fp);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
// Check outputs for generated file paths (plans, review reports, etc.)
|
|
796
|
-
if (sub.outputs) {
|
|
797
|
-
for (const o of sub.outputs) {
|
|
798
|
-
// Only track output files that look like actual generated docs
|
|
799
|
-
if (
|
|
800
|
-
o.includes(".pi-dev-output") ||
|
|
801
|
-
o.includes(".md") ||
|
|
802
|
-
o.includes("review-") ||
|
|
803
|
-
o.includes("pi-plans") ||
|
|
804
|
-
o.includes("pi-review")
|
|
805
|
-
) {
|
|
806
|
-
if (!outputFiles.includes(o)) {
|
|
807
|
-
outputFiles.push(o);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// ── Merge all file paths, deduplicate ─────────────────────
|
|
816
|
-
const allPaths = new Map<string, "edit" | "new" | "delete" | "output">();
|
|
817
|
-
for (const fp of editFiles) allPaths.set(fp, "edit");
|
|
818
|
-
for (const fp of newFiles) allPaths.set(fp, "new");
|
|
819
|
-
for (const fp of delFiles) allPaths.set(fp, "delete");
|
|
820
|
-
for (const fp of outputFiles) {
|
|
821
|
-
if (!allPaths.has(fp)) allPaths.set(fp, "output");
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// ── Build multi-level tree ────────────────────────────────
|
|
825
|
-
interface TreeNode {
|
|
826
|
-
name: string;
|
|
827
|
-
children: Map<string, TreeNode>;
|
|
828
|
-
isFile: boolean;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function buildTree(paths: Iterable<string>): TreeNode {
|
|
832
|
-
const root: TreeNode = { name: "", children: new Map(), isFile: false };
|
|
833
|
-
for (const filePath of paths) {
|
|
834
|
-
const parts = filePath.split("/");
|
|
835
|
-
let current = root;
|
|
836
|
-
for (let i = 0; i < parts.length; i++) {
|
|
837
|
-
const part = parts[i]!;
|
|
838
|
-
const isLast = i === parts.length - 1;
|
|
839
|
-
let child = current.children.get(part);
|
|
840
|
-
if (!child) {
|
|
841
|
-
child = { name: part, children: new Map(), isFile: false };
|
|
842
|
-
current.children.set(part, child);
|
|
843
|
-
}
|
|
844
|
-
current = child;
|
|
845
|
-
if (isLast) current.isFile = true;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
return root;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
function renderTreeNode(node: TreeNode, prefix: string, isLast: boolean, lines: string[]): void {
|
|
852
|
-
const connector = isLast ? "└── " : "├── ";
|
|
853
|
-
lines.push(`${prefix}${connector}${node.name}`);
|
|
854
|
-
renderChildren(node, prefix + (isLast ? " " : "│ "), lines);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function renderChildren(node: TreeNode, prefix: string, lines: string[]): void {
|
|
858
|
-
const entries = [...node.children.entries()].sort((a, b) => {
|
|
859
|
-
// Directories before files, then alphabetical
|
|
860
|
-
if (a[1].isFile !== b[1].isFile) return a[1].isFile ? 1 : -1;
|
|
861
|
-
return a[0].localeCompare(b[0]);
|
|
862
|
-
});
|
|
863
|
-
for (let i = 0; i < entries.length; i++) {
|
|
864
|
-
const [, child] = entries[i]!;
|
|
865
|
-
const isLastChild = i === entries.length - 1;
|
|
866
|
-
if (child.children.size > 0 && !child.isFile) {
|
|
867
|
-
// Directory node (has children, not marked as file)
|
|
868
|
-
renderTreeNode(child, prefix, isLastChild, lines);
|
|
869
|
-
} else {
|
|
870
|
-
// Leaf node (file or empty directory)
|
|
871
|
-
const connector = isLastChild ? "└── " : "├── ";
|
|
872
|
-
lines.push(`${prefix}${connector}${child.name}`);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const tree = buildTree(allPaths.keys());
|
|
878
|
-
const treeLines: string[] = [];
|
|
879
|
-
if (tree.children.size > 0) {
|
|
880
|
-
renderChildren(tree, "", treeLines);
|
|
881
|
-
} else {
|
|
882
|
-
treeLines.push("(无文件变更)");
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// ── Count by type ─────────────────────────────────────────
|
|
886
|
-
let edits = 0,
|
|
887
|
-
news = 0,
|
|
888
|
-
deletes = 0;
|
|
889
|
-
for (const [fp, type] of allPaths) {
|
|
890
|
-
if (type === "edit") edits++;
|
|
891
|
-
else if (type === "new" || type === "output") news++;
|
|
892
|
-
else if (type === "delete") deletes++;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return {
|
|
896
|
-
edits,
|
|
897
|
-
news,
|
|
898
|
-
deletes,
|
|
899
|
-
treeText: treeLines.join("\n"),
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Helper: build a human-readable task summary from the prompt.
|
|
905
|
-
* Extracts the first line/type tag from the prompt and produces a clean summary.
|
|
906
|
-
*
|
|
907
|
-
* Examples:
|
|
908
|
-
* Input: "[feat] 在 auth 模块中实现用户登录"
|
|
909
|
-
* Output: "feat - 在 auth 模块中实现用户登录"
|
|
910
|
-
*
|
|
911
|
-
* Input: "[fix] 修复 login.ts 中的 401 错误"
|
|
912
|
-
* Output: "fix - 修复 login.ts 中的 401 错误"
|
|
913
|
-
*/
|
|
914
|
-
function extractTaskSummary(prompt: string): string {
|
|
915
|
-
const firstLine = prompt.split("\n").find((l) => l.trim()) ?? "";
|
|
916
|
-
// Match [feat] xxx or [fix] xxx or similar
|
|
917
|
-
const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/);
|
|
918
|
-
if (tagMatch) {
|
|
919
|
-
const tag = tagMatch[1]!.trim();
|
|
920
|
-
const rest = tagMatch[2]!.trim();
|
|
921
|
-
// If the rest looks like placeholder dots, try to find a better summary
|
|
922
|
-
if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") {
|
|
923
|
-
// Try the second line or use the tag as fallback
|
|
924
|
-
const lines = prompt.split("\n").filter((l) => l.trim());
|
|
925
|
-
if (lines.length > 1) {
|
|
926
|
-
const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim();
|
|
927
|
-
if (secondLine && !secondLine.startsWith("**")) {
|
|
928
|
-
return `${tag} - ${secondLine.substring(0, 60)}`;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
// Try to find any meaningful content in the prompt
|
|
932
|
-
for (const line of lines.slice(1, 5)) {
|
|
933
|
-
const cleaned = line.replace(/^[*\s#]+/, "").trim();
|
|
934
|
-
if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) {
|
|
935
|
-
const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned;
|
|
936
|
-
return `${tag} - ${summary}`;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
return `${tag} - 工作流任务`;
|
|
940
|
-
}
|
|
941
|
-
return `${tag} - ${rest}`;
|
|
942
|
-
}
|
|
943
|
-
// Fallback: first meaningful line (up to 60 chars)
|
|
944
|
-
const cleaned = firstLine.replace(/^[*\s#]+/, "").trim();
|
|
945
|
-
return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务";
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Send a workflow completion message to the session for persistence.
|
|
950
|
-
* Design: 严格要求的完成后的新ui:
|
|
951
|
-
*
|
|
952
|
-
* [dev-workflow-result]
|
|
953
|
-
* [feat - 添加xxx功能]
|
|
954
|
-
*
|
|
955
|
-
* 🎉 工作流全部完成 (6m8s)
|
|
956
|
-
*
|
|
957
|
-
* ✅ 📋 生成实施计划 (1m33s)
|
|
958
|
-
* ✅ 🔧 实施代码 → 审查 (3m20s)
|
|
959
|
-
*
|
|
960
|
-
* 变动文件:
|
|
961
|
-
* ├── extensions
|
|
962
|
-
* │ ├── xxxx1.ts
|
|
963
|
-
* │ ├── xxxx2.ts
|
|
964
|
-
* ├── .pi-dev-output
|
|
965
|
-
* │ ├── pi-plans
|
|
966
|
-
* │ │ └── 20260519-2155-workflow-ui-async-refactor.md
|
|
967
|
-
* ├── tests
|
|
968
|
-
* │ ├── test-workflow-engine.mjs
|
|
969
|
-
*
|
|
970
|
-
* 完成 2/2 步子代理任务,修改 2 个文件,新增 8 个文件
|
|
971
|
-
*/
|
|
972
|
-
export function sendWorkflowResult(
|
|
973
|
-
pi: ExtensionAPI,
|
|
974
|
-
state: WorkflowWidgetState,
|
|
975
|
-
prompt: string,
|
|
976
|
-
workflowType?: string,
|
|
977
|
-
): void {
|
|
978
|
-
const totalDur = formatDurationFull(Date.now() - state.startedAt);
|
|
979
|
-
const doneCount = state.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
|
980
|
-
const failedCount = state.steps.filter((s) => s.status === "failed").length;
|
|
981
|
-
const total = state.steps.length;
|
|
982
|
-
|
|
983
|
-
const resultIcon = state.status === "done" ? "🎉" : state.status === "failed" ? "❌" : "⏹️";
|
|
984
|
-
const statusText = state.status === "done" ? "全部完成" : state.status === "failed" ? "部分失败" : "已取消";
|
|
985
|
-
|
|
986
|
-
// Count sub-agent runs (total sub-step executions)
|
|
987
|
-
let subAgentRuns = 0;
|
|
988
|
-
for (const s of state.steps) {
|
|
989
|
-
if (s.subSteps) subAgentRuns += s.subSteps.length;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// Extract file changes from step states
|
|
993
|
-
const fileChanges = extractFileChanges(state.steps);
|
|
994
|
-
|
|
995
|
-
// Build task summary from prompt
|
|
996
|
-
const taskSummary = extractTaskSummary(prompt);
|
|
997
|
-
|
|
998
|
-
// Build step summary lines
|
|
999
|
-
const stepSummaryParts: string[] = [];
|
|
1000
|
-
for (const s of state.steps) {
|
|
1001
|
-
const icon = s.status === "done" ? "✅" : s.status === "failed" ? "❌" : s.status === "skipped" ? "⏭️" : "⬜";
|
|
1002
|
-
const durSuffix = s.durationMs != null ? ` (${formatDurationFull(s.durationMs)})` : "";
|
|
1003
|
-
const errSuffix = s.status === "failed" && s.error ? ` — ${s.error}` : "";
|
|
1004
|
-
stepSummaryParts.push(`${icon} **${s.label}**${durSuffix}${errSuffix}`);
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const body = [
|
|
1008
|
-
`[${taskSummary}]`,
|
|
1009
|
-
"",
|
|
1010
|
-
`${resultIcon} **工作流${statusText}** (${totalDur})`,
|
|
1011
|
-
state.workflowId ? `工作流 UUID: \`${state.workflowId}\`` : "",
|
|
1012
|
-
"",
|
|
1013
|
-
stepSummaryParts.join("\n"),
|
|
1014
|
-
"",
|
|
1015
|
-
"变动文件:",
|
|
1016
|
-
"```",
|
|
1017
|
-
fileChanges.treeText,
|
|
1018
|
-
"```",
|
|
1019
|
-
"",
|
|
1020
|
-
`完成 ${doneCount}/${total} 步子代理任务,修改 ${fileChanges.edits} 个文件,新增 ${fileChanges.news} 个文件` +
|
|
1021
|
-
(fileChanges.deletes > 0 ? `,删除 ${fileChanges.deletes} 个文件` : "") +
|
|
1022
|
-
(failedCount > 0 ? `,${failedCount} 步失败` : ""),
|
|
1023
|
-
].join("\n");
|
|
1024
|
-
|
|
1025
|
-
try {
|
|
1026
|
-
pi.sendMessage({
|
|
1027
|
-
customType: "dev-workflow-result",
|
|
1028
|
-
content: body,
|
|
1029
|
-
display: true,
|
|
1030
|
-
details: {
|
|
1031
|
-
status: state.status,
|
|
1032
|
-
steps: state.steps,
|
|
1033
|
-
durationMs: Date.now() - state.startedAt,
|
|
1034
|
-
workflowType,
|
|
1035
|
-
prompt,
|
|
1036
|
-
taskSummary,
|
|
1037
|
-
fileChanges: { edits: fileChanges.edits, news: fileChanges.news, deletes: fileChanges.deletes },
|
|
1038
|
-
subAgentRuns,
|
|
1039
|
-
},
|
|
1040
|
-
});
|
|
1041
|
-
} catch {
|
|
1042
|
-
console.log(`[workflow] ${body}`);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ── Dynamic progress update ──────────────────────────────────
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
1049
|
-
* Helper to build WorkflowWidgetState from step states.
|
|
1050
|
-
*/
|
|
1051
|
-
export function buildWidgetState(
|
|
1052
|
-
mode: string,
|
|
1053
|
-
steps: WorkflowStepWidgetState[],
|
|
1054
|
-
currentStepIndex: number,
|
|
1055
|
-
startedAt: number,
|
|
1056
|
-
status: WorkflowWidgetState["status"],
|
|
1057
|
-
extra?: { toolCount?: number; tokenCount?: number },
|
|
1058
|
-
taskSummary?: string,
|
|
1059
|
-
workflowId?: string,
|
|
1060
|
-
): WorkflowWidgetState {
|
|
1061
|
-
return {
|
|
1062
|
-
mode,
|
|
1063
|
-
steps,
|
|
1064
|
-
currentStepIndex,
|
|
1065
|
-
startedAt,
|
|
1066
|
-
status,
|
|
1067
|
-
updatedAt: new Date().toISOString(),
|
|
1068
|
-
toolCount: extra?.toolCount,
|
|
1069
|
-
tokenCount: extra?.tokenCount,
|
|
1070
|
-
taskSummary,
|
|
1071
|
-
workflowId,
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
297
|
// ═══════════════════════════════════════════════════════════════
|
|
1076
298
|
// Extension factory (no-op — ui-helpers is a helper module)
|
|
1077
299
|
// ═══════════════════════════════════════════════════════════════
|
|
1078
300
|
|
|
1079
301
|
export default function (_pi: ExtensionAPI) {
|
|
1080
302
|
// ui-helpers is a helper module, imported by other extensions.
|
|
1081
|
-
}
|
|
303
|
+
}
|