@ghyper9023/pi-dev-workflow 0.3.3 → 0.4.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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 职责:
5
5
  * 1. runGrillPhase() — 启动 sub-agent 生成评审问题,TUI 逐题呈现(选项 + 自定义输入)
6
- * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 pi-dev-output/pi-prd/
6
+ * 2. runPRDPhase() — 启动 sub-agent 生成 PRD,保存到 .pi-dev-output/pi-prd/
7
7
  *
8
8
  * 关键设计决策(修复 #2):
9
9
  * sub-agent 通过 `write` 工具将评审问题写入临时文件,主进程事后读取。
@@ -23,6 +23,7 @@ import {
23
23
  Spacer,
24
24
  type SelectItem,
25
25
  } from "@earendil-works/pi-tui";
26
+ import { uiSelect, uiConfirm, uiInput } from "./ui-helpers";
26
27
 
27
28
  // ── Types ────────────────────────────────────────────────────
28
29
 
@@ -53,51 +54,56 @@ export interface PRDResult {
53
54
 
54
55
  // ── Output dirs ──────────────────────────────────────────────
55
56
 
56
- const DEV_OUTPUT_DIR = "pi-dev-output";
57
+ const DEV_OUTPUT_DIR = ".pi-dev-output";
57
58
  const GRILL_DIRNAME = "pi-grill";
59
+ const GRILL_ANSWERS_DIRNAME = "answers";
60
+ const GRILL_QUESTIONS_DIRNAME = "questions";
58
61
  const PRD_DIRNAME = "pi-prd";
59
62
 
60
- /** Ensure an output subdirectory exists and write .gitignore. */
63
+ /** Ensure an output subdirectory exists. */
61
64
  function ensureOutputDir(cwd: string, subdir: string): string {
62
65
  const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
63
66
  fs.mkdirSync(dir, { recursive: true });
64
- const gitignorePath = path.join(cwd, DEV_OUTPUT_DIR, ".gitignore");
65
- try {
66
- const existing = fs.readFileSync(gitignorePath, "utf-8").trim();
67
- if (!existing.includes("*")) {
68
- fs.writeFileSync(gitignorePath, "*\n!.gitignore\n");
69
- }
70
- } catch {
71
- fs.writeFileSync(gitignorePath, "*\n!.gitignore\n");
72
- }
73
67
  return dir;
74
68
  }
75
69
 
76
- /** Generate a safe temp filename for grill output. */
70
+ /** Format current time as YYYYMMDD-HHmm for human-readable timestamps. */
71
+ function formatTimestamp(): string {
72
+ const now = new Date();
73
+ const Y = now.getFullYear().toString();
74
+ const M = (now.getMonth() + 1).toString().padStart(2, "0");
75
+ const D = now.getDate().toString().padStart(2, "0");
76
+ const h = now.getHours().toString().padStart(2, "0");
77
+ const m = now.getMinutes().toString().padStart(2, "0");
78
+ return `${Y}${M}${D}-${h}${m}`;
79
+ }
80
+
81
+ /** Generate a safe temp filename for grill output (pi-grill/questions/questions-<id>-<YYYYMMDD-HHmm>.json). */
77
82
  function grillOutputPath(cwd: string): string {
78
- const dir = ensureOutputDir(cwd, GRILL_DIRNAME);
83
+ const dir = ensureOutputDir(cwd, path.join(GRILL_DIRNAME, GRILL_QUESTIONS_DIRNAME));
79
84
  const ts = Date.now().toString(36);
80
- return path.join(dir, `questions-${ts}.json`);
85
+ return path.join(dir, `questions-${ts}-${formatTimestamp()}.json`);
81
86
  }
82
87
 
83
88
  /**
84
- * Save the final assembled prompt to a timestamped answer file.
89
+ * Save the final assembled prompt to a timestamped answer file (pi-grill/answers/answer-<id>-<YYYYMMDD-HHmm>.md).
85
90
  * Returns the relative path from cwd (for display in notifications).
86
91
  */
87
92
  export function saveAnswerFile(cwd: string, content: string): string {
88
- const dir = ensureOutputDir(cwd, GRILL_DIRNAME);
93
+ const dir = ensureOutputDir(cwd, path.join(GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME));
89
94
  const ts = Date.now().toString(36);
90
- const filename = `answer-${ts}.md`;
95
+ const filename = `answer-${ts}-${formatTimestamp()}.md`;
91
96
  fs.writeFileSync(path.join(dir, filename), content, "utf-8");
92
- return path.join(DEV_OUTPUT_DIR, GRILL_DIRNAME, filename);
97
+ return path.join(DEV_OUTPUT_DIR, GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME, filename);
93
98
  }
94
99
 
95
100
  /**
96
101
  * Find the most recent answer backup file and read its content.
97
102
  * Returns undefined if no backup exists or read fails.
103
+ * Now reads from pi-grill/answers/ subdirectory.
98
104
  */
99
105
  export function recoverFromBackup(cwd: string): string | undefined {
100
- const dir = path.join(cwd, DEV_OUTPUT_DIR, GRILL_DIRNAME);
106
+ const dir = path.join(cwd, DEV_OUTPUT_DIR, GRILL_DIRNAME, GRILL_ANSWERS_DIRNAME);
101
107
  try {
102
108
  if (!fs.existsSync(dir)) return undefined;
103
109
  const files = fs.readdirSync(dir)
@@ -363,14 +369,15 @@ export async function runGrillPhase(
363
369
 
364
370
  const agentDef = options?.agentDef ?? _defaultGrillAgent;
365
371
  const confirmTitle = options?.title ?? "🔍 设计方案评审";
366
- const confirmDesc = options?.description ?? "是否进入设计评审 (Grill) 模式?\nAI 会从架构、数据流、边界条件、安全等多个维度挑战你的设计。";
372
+ const confirmDesc = options?.description ?? "AI 会从架构、数据流、边界条件、安全等多个维度挑战你的设计。";
367
373
  const qTitlePrefix = options?.questionTitle ?? "设计方案评审";
368
374
  const loaderLabel = options?.loaderLabel ?? "🧠 AI 子代理正在分析代码并生成评审问题...";
369
375
 
370
376
  // ── Step 1: Confirm entering grill mode ──────────────────
371
- const enterGrill = await ctx.ui.confirm(confirmTitle, confirmDesc);
377
+ const enterGrill = await uiConfirm(ctx, confirmTitle, confirmDesc);
372
378
  if (!enterGrill) {
373
- return { ...defaultResult, cancelled: true };
379
+ // Skip grill but continue the workflow (not a cancellation)
380
+ return defaultResult;
374
381
  }
375
382
 
376
383
  // ── Step 2: Prepare output file + enhanced prompt ─────────
@@ -392,15 +399,12 @@ export async function runGrillPhase(
392
399
  loader.signal,
393
400
  undefined,
394
401
  (progress) => {
395
- // BorderedLoader 内部持有 Loader/CancellableLoader(私有 loader 字段),后者有 setText
396
402
  const inner = (loader as unknown as { loader?: { setText?: (t: string) => void } }).loader;
397
403
  inner?.setText?.(`🧠 ${progress.slice(0, 60)}`);
398
404
  },
399
405
  )
400
406
  .then((result) => {
401
- // Primary: read from file (sub-agent wrote via `write` tool)
402
407
  let qs = readQuestionsFromFile(outputFilePath);
403
- // Fallback: parse from NDJSON response text
404
408
  if (qs.length === 0) {
405
409
  const output = extractFinalOutput(result.output);
406
410
  qs = parseGrillQuestions(output);
@@ -408,7 +412,6 @@ export async function runGrillPhase(
408
412
  done(qs);
409
413
  })
410
414
  .catch(() => {
411
- // On error, still try to read file (sub-agent may have written before error)
412
415
  const qs = readQuestionsFromFile(outputFilePath);
413
416
  done(qs);
414
417
  });
@@ -418,7 +421,6 @@ export async function runGrillPhase(
418
421
 
419
422
  // ── Step 4: Retry dialog if no questions generated ──────
420
423
  if (questions.length === 0) {
421
- // Read the failed file to get error context for the retry prompt
422
424
  let failedFileContent = "";
423
425
  let parseErrorMsg = "";
424
426
  try {
@@ -430,7 +432,8 @@ export async function runGrillPhase(
430
432
  parseErrorMsg = (e as Error).message;
431
433
  }
432
434
 
433
- const choice = await ctx.ui.select(
435
+ const choice = await uiSelect(
436
+ ctx,
434
437
  "⚠️ AI 未能成功生成评审问题",
435
438
  [
436
439
  "🔄 重新尝试生成评审问题",
@@ -464,7 +467,7 @@ export async function runGrillPhase(
464
467
  writeToolPromptSuffix(retryPath),
465
468
  errorFeedback,
466
469
  ].filter(Boolean).join("\n\n");
467
- questions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
470
+ const retryQuestions = await ctx.ui.custom<GrillQuestion[]>((tui, theme, _kb, done) => {
468
471
  const loader = new BorderedLoader(tui, theme, loaderLabel);
469
472
  loader.onAbort = () => done([]);
470
473
  spawnSubagent(agentDef, retryPrompt, ctx.cwd, loader.signal, undefined)
@@ -476,37 +479,91 @@ export async function runGrillPhase(
476
479
  .catch(() => done([]));
477
480
  return loader;
478
481
  });
479
- if (questions.length === 0) {
480
- ctx.ui.notify("⚠️ 再次尝试仍然失败,跳过 Grill 阶段", "warning");
482
+ if (retryQuestions.length === 0) {
481
483
  return defaultResult;
482
484
  }
483
- break;
485
+ // Replace questions with retry results (with back support)
486
+ const pairs: Array<{ question: string; answer: string }> = [];
487
+ let rIdx = 0;
488
+ while (rIdx >= 0 && rIdx < retryQuestions.length) {
489
+ const q = retryQuestions[rIdx]!;
490
+ const previousAnswer = pairs[rIdx]?.answer;
491
+ const answer = await showQuestionTUI(ctx, q, rIdx + 1, retryQuestions.length, qTitlePrefix,
492
+ rIdx > 0, previousAnswer);
493
+ if (answer === null) {
494
+ return { ...defaultResult, cancelled: true, pairs };
495
+ }
496
+ if (answer === "__BACK__") {
497
+ if (rIdx > 0) {
498
+ rIdx--;
499
+ continue;
500
+ }
501
+ return { ...defaultResult, cancelled: true, pairs };
502
+ }
503
+ // Overwrite if re-answering, otherwise append
504
+ if (rIdx < pairs.length) {
505
+ pairs[rIdx] = { question: q.question, answer };
506
+ } else {
507
+ pairs.push({ question: q.question, answer });
508
+ }
509
+ rIdx++;
510
+ }
511
+ const qaBlock = pairs
512
+ .map((p, i) => `[评审问题 ${i + 1}]\n问题: ${p.question}\n回答: ${p.answer}`)
513
+ .join("\n\n");
514
+ const finalEnhancedPrompt = [
515
+ assembledPrompt,
516
+ "",
517
+ "---",
518
+ "## 设计评审记录",
519
+ "",
520
+ "以下是在开发前进行的设计评审问答,所有决策已确认:",
521
+ "",
522
+ qaBlock,
523
+ ].join("\n");
524
+ return {
525
+ cancelled: false,
526
+ pairs,
527
+ enhancedPrompt: finalEnhancedPrompt,
528
+ };
484
529
  }
485
530
  case "⏭️ 跳过 Grill,直接发送 Prompt":
486
- ctx.ui.notify("⏭️ 已跳过 Grill 阶段", "info");
487
531
  return defaultResult;
488
532
  case "❌ 取消 (Esc)":
489
533
  default:
490
- ctx.ui.notify("❌ 操作已取消", "warning");
491
534
  return { ...defaultResult, cancelled: true };
492
535
  }
493
536
  }
494
537
 
495
- ctx.ui.notify(`✅ AI 生成了 ${questions.length} 个评审问题`, "success");
496
-
497
- // ── Step 5: TUI — present questions one by one ───────────
538
+ // ── Step 5: TUI — present questions one by one (with back support) ──
498
539
  const pairs: Array<{ question: string; answer: string }> = [];
540
+ let qIdx = 0;
499
541
 
500
- for (let idx = 0; idx < questions.length; idx++) {
501
- const q = questions[idx];
502
- const answer = await showQuestionTUI(ctx, q, idx + 1, questions.length, qTitlePrefix);
542
+ while (qIdx >= 0 && qIdx < questions.length) {
543
+ const q = questions[qIdx]!;
544
+ const previousAnswer = pairs[qIdx]?.answer;
545
+ const answer = await showQuestionTUI(ctx, q, qIdx + 1, questions.length, qTitlePrefix,
546
+ qIdx > 0, previousAnswer);
503
547
 
504
548
  if (answer === null) {
505
- ctx.ui.notify("❌ 评审已取消", "warning");
506
549
  return { ...defaultResult, cancelled: true, pairs };
507
550
  }
508
551
 
509
- pairs.push({ question: q.question, answer });
552
+ if (answer === "__BACK__") {
553
+ if (qIdx > 0) {
554
+ qIdx--;
555
+ continue;
556
+ }
557
+ return { ...defaultResult, cancelled: true, pairs };
558
+ }
559
+
560
+ // Overwrite if re-answering (back then forward), otherwise append
561
+ if (qIdx < pairs.length) {
562
+ pairs[qIdx] = { question: q.question, answer };
563
+ } else {
564
+ pairs.push({ question: q.question, answer });
565
+ }
566
+ qIdx++;
510
567
  }
511
568
 
512
569
  // ── Step 6: Assemble enhanced prompt ─────────────────────
@@ -525,8 +582,6 @@ export async function runGrillPhase(
525
582
  qaBlock,
526
583
  ].join("\n");
527
584
 
528
- ctx.ui.notify(`✅ 评审完成,共 ${pairs.length} 道问题`, "success");
529
-
530
585
  return {
531
586
  cancelled: false,
532
587
  pairs,
@@ -541,17 +596,32 @@ async function showQuestionTUI(
541
596
  currentIndex: number,
542
597
  totalCount: number,
543
598
  titlePrefix = "设计方案评审",
599
+ backable = false,
600
+ previousAnswer?: string,
544
601
  ): Promise<string | null> {
545
602
  const selectItems: SelectItem[] = q.options.map((opt, i) => ({
546
603
  value: `opt-${i}`,
547
- label: `(${String.fromCharCode(97 + i)}) ${opt}`,
604
+ label: opt === previousAnswer
605
+ ? `(${String.fromCharCode(97 + i)}) ${opt} - 上次选择`
606
+ : `(${String.fromCharCode(97 + i)}) ${opt}`,
548
607
  }));
608
+
609
+ const customLabel = previousAnswer && !q.options.includes(previousAnswer)
610
+ ? `✏️ 自定义输入 - 上次选择`
611
+ : `✏️ 自定义输入`;
549
612
  selectItems.push({
550
613
  value: "__custom__",
551
- label: "✏️ 自定义输入",
614
+ label: customLabel,
552
615
  description: "输入你自己的回答,不受选项限制",
553
616
  });
554
617
 
618
+ if (backable && currentIndex > 1) {
619
+ selectItems.push({
620
+ value: "__back__",
621
+ label: "← 返回上一题",
622
+ });
623
+ }
624
+
555
625
  const title = `${titlePrefix} (问题 ${currentIndex}/${totalCount})`;
556
626
 
557
627
  const value = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
@@ -575,8 +645,11 @@ async function showQuestionTUI(
575
645
  container.addChild(selectList);
576
646
 
577
647
  container.addChild(new Spacer(1));
648
+ const hint = backable && currentIndex > 1
649
+ ? " ↑↓ 导航 • Enter 选择 • 选择←返回上一题 • Esc 取消全部评审"
650
+ : " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审";
578
651
  container.addChild(
579
- new Text(theme.fg("dim", " ↑↓ 导航 • Enter 选择 • Esc 取消全部评审"), 0, 0),
652
+ new Text(theme.fg("dim", hint), 0, 0),
580
653
  );
581
654
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
582
655
 
@@ -590,13 +663,18 @@ async function showQuestionTUI(
590
663
  };
591
664
  });
592
665
 
666
+ if (value === "__back__") return "__BACK__";
593
667
  if (value === null) return null;
594
668
  if (value === "__custom__") {
595
- const custom = await ctx.ui.input("✏️ 自定义回答", {
596
- placeholder: "输入你的回答内容(Esc 取消本题,回到选项)",
597
- required: false,
598
- });
599
- if (custom === undefined) return showQuestionTUI(ctx, q, currentIndex, totalCount);
669
+ const custom = await uiInput(ctx, "✏️ 自定义回答",
670
+ previousAnswer && !q.options.includes(previousAnswer)
671
+ ? `(上次: ${previousAnswer.slice(0, 60)})`
672
+ : "输入你的回答内容(Esc 取消本题,回到选项)",
673
+ false, true,
674
+ previousAnswer && !q.options.includes(previousAnswer) ? previousAnswer : "",
675
+ );
676
+ if (custom === "__BACK__") return "__BACK__";
677
+ if (custom === undefined) return showQuestionTUI(ctx, q, currentIndex, totalCount, titlePrefix, backable, previousAnswer);
600
678
  return custom.trim() || "(空)";
601
679
  }
602
680
 
@@ -610,7 +688,7 @@ async function showQuestionTUI(
610
688
  * Run the PRD phase:
611
689
  * 1. Ask user if they want to create a PRD
612
690
  * 2. Call sub-agent → gets PRD Markdown
613
- * 3. Save to pi-dev-output/pi-prd/<name>.md
691
+ * 3. Save to .pi-dev-output/pi-prd/<name>.md
614
692
  * 4. Ask if user wants to start development
615
693
  */
616
694
  export async function runPRDPhase(
@@ -619,9 +697,10 @@ export async function runPRDPhase(
619
697
  pi: ExtensionAPI,
620
698
  ctx: ExtensionCommandContext,
621
699
  ): Promise<PRDResult | null> {
622
- const wantPrd = await ctx.ui.confirm(
700
+ const wantPrd = await uiConfirm(
701
+ ctx,
623
702
  "📋 创建 PRD",
624
- "是否为此功能创建 PRD 文档?\nPRD 将保存到 pi-dev-output/pi-prd/ 目录。",
703
+ "PRD 将保存到 .pi-dev-output/pi-prd/ 目录。",
625
704
  );
626
705
  if (!wantPrd) return null;
627
706
 
@@ -648,7 +727,6 @@ export async function runPRDPhase(
648
727
  });
649
728
 
650
729
  if (!prdContent) {
651
- ctx.ui.notify("⚠️ PRD 生成失败", "error");
652
730
  return null;
653
731
  }
654
732
 
@@ -657,7 +735,6 @@ export async function runPRDPhase(
657
735
  const filePath = path.join(DEV_OUTPUT_DIR, PRD_DIRNAME, filename);
658
736
  const fullPath = path.join(prdDir, filename);
659
737
  fs.writeFileSync(fullPath, prdContent, "utf-8");
660
- ctx.ui.notify(`✅ PRD 已保存到 ${filePath}`, "success");
661
738
 
662
739
  await askDevelopmentStart(pi, ctx, prdContent, filePath);
663
740
  return { content: prdContent, filePath };
@@ -669,7 +746,8 @@ async function askDevelopmentStart(
669
746
  prdContent: string,
670
747
  prdFilePath: string,
671
748
  ): Promise<void> {
672
- const choice = await ctx.ui.select(
749
+ const choice = await uiSelect(
750
+ ctx,
673
751
  "🚀 是否开始开发?",
674
752
  [
675
753
  "是 — 根据 PRD 开始开发",
@@ -694,17 +772,12 @@ async function askDevelopmentStart(
694
772
  "请按照上述 PRD 逐步实现。先分析代码库结构,给出实施计划,确认后再编写代码。",
695
773
  ].join("\n");
696
774
  pi.sendUserMessage(devMsg, { deliverAs: "followUp" });
697
- ctx.ui.notify("🚀 已发送开发指令给主代理", "success");
698
775
  break;
699
776
  }
700
777
  case "否 — 稍后手动开始":
701
- ctx.ui.notify(`📋 PRD 已保存在 ${prdFilePath},可随时手动引用`, "info");
702
778
  break;
703
779
  default: {
704
- const customMsg = await ctx.ui.input("✏️ 自定义开发指令", {
705
- placeholder: "输入你的开发指令(将结合 PRD 一起发送给主代理)",
706
- required: false,
707
- });
780
+ const customMsg = await uiInput(ctx, "✏️ 自定义开发指令", "输入你的开发指令(将结合 PRD 一起发送给主代理)");
708
781
  if (customMsg === undefined) return askDevelopmentStart(pi, ctx, prdContent, prdFilePath);
709
782
  const finalMsg = customMsg.trim()
710
783
  ? [
@@ -724,7 +797,6 @@ async function askDevelopmentStart(
724
797
  prdContent,
725
798
  ].join("\n");
726
799
  pi.sendUserMessage(finalMsg, { deliverAs: "followUp" });
727
- ctx.ui.notify("🚀 已发送自定义开发指令给主代理", "success");
728
800
  break;
729
801
  }
730
802
  }
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
19
  import { Type } from "typebox";
20
+ import { uiSelect } from "./ui-helpers";
20
21
 
21
22
  // ── Configuration ────────────────────────────────────────────
22
23
 
@@ -70,11 +71,12 @@ function loadAppendSystem(cwd: string): string | null {
70
71
  return null;
71
72
  }
72
73
 
73
- /** Find the newest HTML review file in pi-review/ or pi-dev-output/pi-review/ directory. */
74
+ /** Find the newest HTML review file in .pi-dev-output/pi-review/html/, pi-review/, or .pi-dev-output/pi-review/ directory. */
74
75
  function findNewestReviewHtml(cwd: string): string {
75
76
  const candidates = [
77
+ path.join(cwd, ".pi-dev-output", "pi-review", "html"),
76
78
  path.join(cwd, "pi-review"),
77
- path.join(cwd, "pi-dev-output", "pi-review"),
79
+ path.join(cwd, ".pi-dev-output", "pi-review"),
78
80
  ];
79
81
 
80
82
  for (const reviewDir of candidates) {
@@ -552,17 +554,35 @@ export default function (pi: ExtensionAPI) {
552
554
 
553
555
  // ── /subagent-stop - 主动终止所有正在运行的 sub-agent ──────
554
556
  pi.registerCommand("subagent-stop", {
555
- description: "Terminate all running sub-agents immediately",
557
+ description: "Terminate all running sub-agents immediately. Also cancels any active workflow.",
556
558
  handler: async (_args, ctx) => {
557
- const count = activeChildren.size;
558
- if (count === 0) {
559
- ctx.ui.notify("i️ 当前没有运行中的 sub-agent", "info");
559
+ const childCount = activeChildren.size;
560
+
561
+ // Also try to cancel any active workflow
562
+ let workflowCancelled = false;
563
+ try {
564
+ const { cancelActiveWorkflow, isWorkflowRunning } = await import("./workflow-engine");
565
+ if (isWorkflowRunning()) {
566
+ cancelActiveWorkflow();
567
+ workflowCancelled = true;
568
+ }
569
+ } catch { /* workflow-engine not available, ignore */ }
570
+
571
+ if (childCount === 0 && !workflowCancelled) {
572
+ ctx.ui.notify("i️ 当前没有运行中的 sub-agent 或工作流", "info");
560
573
  return;
561
574
  }
562
- killAllChildren();
563
- ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
564
- ctx.ui.notify(`🛑 已终止 ${count} 个 sub-agent 进程`, "warning");
565
- ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
575
+
576
+ if (childCount > 0) {
577
+ killAllChildren();
578
+ ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
579
+ ctx.ui.notify(`🛑 已终止 ${childCount} 个 sub-agent 进程`, "warning");
580
+ ctx.ui.notify(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, "info");
581
+ }
582
+
583
+ if (workflowCancelled) {
584
+ ctx.ui.notify(`🛑 已取消运行中的工作流`, "warning");
585
+ }
566
586
  },
567
587
  });
568
588
 
@@ -670,7 +690,8 @@ export default function (pi: ExtensionAPI) {
670
690
 
671
691
  // 对于普通关键词触发的审查请求,询问用户选择模式
672
692
  // ctx.ui.select 接受 string[],返回选中的字符串
673
- const mode = await ctx.ui.select(
693
+ const mode = await uiSelect(
694
+ ctx,
674
695
  "🔍 检测到审查意图",
675
696
  [
676
697
  "1. 后台审查(非阻塞,异步通知)",