@zhushanwen/pi-ask-user 4.0.0 → 5.0.0-dev.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "4.0.0",
4
- "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and inline editor.",
3
+ "version": "5.0.0-dev.1",
4
+ "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor and optional comments.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "pi": {
@@ -26,7 +26,7 @@
26
26
  "ARCHITECTURE.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@xyz-agent/extension-protocol": "^0.3.0"
29
+ "@xyz-agent/extension-protocol": "^0.3.1-dev.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@earendil-works/pi-tui": "*",
@@ -38,7 +38,7 @@
38
38
  "@earendil-works/pi-coding-agent": "*",
39
39
  "@earendil-works/pi-tui": "*",
40
40
  "typebox": "*",
41
- "@zhushanwen/pi-subagent-workflow": "4.0.0"
41
+ "@zhushanwen/pi-subagent-workflow": "5.0.0-dev.1"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@earendil-works/pi-tui": {
@@ -4,16 +4,23 @@
4
4
  // 覆盖审查 S5 发现的覆盖盲区:
5
5
  // - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
6
6
  // - formatAnswer 空 parts → null
7
+ // - comment 分隔符边界
7
8
 
8
9
  import { describe, expect, it } from "vitest";
9
10
 
10
11
  import { formatAnswer, parseAnswerParts } from "../answer-format.js";
12
+ import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
11
13
 
12
14
  describe("formatAnswer", () => {
13
15
  it("returns null for empty parts (unanswered)", () => {
14
16
  expect(formatAnswer([])).toBeNull();
15
17
  });
16
18
 
19
+ it("returns null for empty parts even with comment", () => {
20
+ // parts 空 = 没有选中选项,即使有 comment 也不应产出有效答案行
21
+ expect(formatAnswer([], "some comment")).toBeNull();
22
+ });
23
+
17
24
  it("joins single part without separator", () => {
18
25
  expect(formatAnswer(["yes"])).toBe("yes");
19
26
  });
@@ -21,6 +28,15 @@ describe("formatAnswer", () => {
21
28
  it("joins multiple parts with ', '", () => {
22
29
  expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
23
30
  });
31
+
32
+ it("appends comment with ANSWER_COMMENT_SEPARATOR", () => {
33
+ const result = formatAnswer(["A", "B"], "my comment");
34
+ expect(result).toBe(`A, B${ANSWER_COMMENT_SEPARATOR}my comment`);
35
+ });
36
+
37
+ it("handles null comment (no separator appended)", () => {
38
+ expect(formatAnswer(["A"], null)).toBe("A");
39
+ });
24
40
  });
25
41
 
26
42
  describe("parseAnswerParts", () => {
@@ -28,6 +44,7 @@ describe("parseAnswerParts", () => {
28
44
  const labels = ["yes", "no", "maybe"];
29
45
  const result = parseAnswerParts("yes, no", labels);
30
46
  expect(result.selected).toEqual(["yes", "no"]);
47
+ expect(result.comment).toBeUndefined();
31
48
  });
32
49
 
33
50
  // S5 核心:防子串误匹配——"A" 不应命中 label "AB"
@@ -52,21 +69,39 @@ describe("parseAnswerParts", () => {
52
69
  expect(result.selected).toEqual(["C", "A"]);
53
70
  });
54
71
 
72
+ it("extracts comment after ANSWER_COMMENT_SEPARATOR", () => {
73
+ const labels = ["yes"];
74
+ const answer = `yes${ANSWER_COMMENT_SEPARATOR}because reasons`;
75
+ const result = parseAnswerParts(answer, labels);
76
+ expect(result.selected).toEqual(["yes"]);
77
+ expect(result.comment).toBe("because reasons");
78
+ });
79
+
55
80
  it("handles full-width comma (,) as separator", () => {
56
81
  const labels = ["A", "B"];
57
82
  const result = parseAnswerParts("A,B", labels);
58
83
  expect(result.selected).toEqual(["A", "B"]);
59
84
  });
60
85
 
61
- it("returns non-matching tokens as neither selected (Other free text)", () => {
86
+ it("returns non-matching tokens as neither selected nor comment (Other free text)", () => {
62
87
  const labels = ["yes", "no"];
63
88
  // "custom text" 不匹配任何 label → 是 Other 自由文本
64
89
  const result = parseAnswerParts("custom text", labels);
65
90
  expect(result.selected).toEqual([]);
91
+ expect(result.comment).toBeUndefined();
66
92
  });
67
93
 
68
94
  it("handles empty answer string", () => {
69
95
  const result = parseAnswerParts("", ["A", "B"]);
70
96
  expect(result.selected).toEqual([]);
97
+ expect(result.comment).toBeUndefined();
98
+ });
99
+
100
+ it("handles answer with only comment (no selected labels)", () => {
101
+ const labels = ["A"];
102
+ const answer = `${ANSWER_COMMENT_SEPARATOR}just a comment`;
103
+ const result = parseAnswerParts(answer, labels);
104
+ expect(result.selected).toEqual([]);
105
+ expect(result.comment).toBe("just a comment");
71
106
  });
72
107
  });
@@ -6,7 +6,7 @@
6
6
  // - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 askUserInteract(select 通道),
7
7
  // 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
8
8
  // - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
9
- // 验证内部 Result → proto AskUserAnswers 重新编码(single/multi/Other 三种答案形态)。
9
+ // 验证内部 Result → proto AskUserAnswers 重新编码(single/multi/Other/comment 四种答案形态)。
10
10
  // - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
11
11
  // - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
12
12
  import type { AskUserQuestion } from "@xyz-agent/extension-protocol";
@@ -75,6 +75,12 @@ const multiProto: AskUserQuestion = {
75
75
  ],
76
76
  };
77
77
 
78
+ const commentProto: AskUserQuestion = {
79
+ question: "Which DB?",
80
+ allowComment: true,
81
+ options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
82
+ };
83
+
78
84
  // ── Tests ───────────────────────────────────────────────
79
85
 
80
86
  describe("createAskUserChannelHandler", () => {
@@ -97,6 +103,19 @@ describe("createAskUserChannelHandler", () => {
97
103
  expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
98
104
  });
99
105
 
106
+ it("RPC: Other + comment proto answers → 透传", async () => {
107
+ const protoAnswers = {
108
+ "Which DB?": "Postgres",
109
+ "Which DB?__other": "Custom DB",
110
+ "Which DB?__comment": "prod constraint",
111
+ };
112
+ const handler = createAskUserChannelHandler(
113
+ makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
114
+ );
115
+ const resp = await handler({ channelPayload: { questions: [commentProto] } });
116
+ expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
117
+ });
118
+
100
119
  it("RPC: user cancel (select undefined) → {cancelled: true}", async () => {
101
120
  const handler = createAskUserChannelHandler(
102
121
  makeCtx({ mode: "rpc", selectResult: undefined }) as never,
@@ -198,6 +217,21 @@ describe("createAskUserChannelHandler", () => {
198
217
  });
199
218
  });
200
219
 
220
+ it("TUI: comment → ${key}__comment", async () => {
221
+ const internalResult: Result = {
222
+ questions: [],
223
+ answers: { "Which DB?": "Postgres — prod constraint" },
224
+ cancelled: false,
225
+ };
226
+ const handler = createAskUserChannelHandler(
227
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
228
+ );
229
+ const resp = await handler({ channelPayload: { questions: [commentProto] } });
230
+ expect(resp).toEqual({
231
+ value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__comment": "prod constraint" }),
232
+ });
233
+ });
234
+
201
235
  it("TUI: user cancel (custom returns null) → {cancelled: true}", async () => {
202
236
  const handler = createAskUserChannelHandler(
203
237
  makeCtx({ mode: "tui", customResult: null }) as never,
@@ -30,6 +30,7 @@ import {
30
30
  INSERT,
31
31
  LEFT,
32
32
  make,
33
+ multiQWithComment,
33
34
  OSC_BEL,
34
35
  OSC_ST,
35
36
  PAGE_DOWN,
@@ -50,6 +51,14 @@ import {
50
51
  UP,
51
52
  } from "./fixtures";
52
53
 
54
+ /** Helper: 打开 comment 编辑器并返回 component(多问题避免单问题 auto-submit) */
55
+ function openComment(): AskUserComponent {
56
+ const { c } = make(multiQWithComment);
57
+ // Q1 (allowComment): select A → enters comment mode
58
+ c.handleInput(ENTER);
59
+ return c;
60
+ }
61
+
53
62
  /** Helper: 打开 freeform 编辑器并返回 component(不渲染,避免缓存) */
54
63
  function openFreeform(q: Question[]): AskUserComponent {
55
64
  const { c } = make(q);
@@ -255,6 +264,25 @@ describe("AskUserComponent — key leak fix (C-ARROW / C-KEYMAP)", () => {
255
264
  expect(editorLine).not.toContain("abc");
256
265
  });
257
266
 
267
+ // ── C-KEYMAP-COMMENT-UP: arrow key no-op in comment editor too ──
268
+ it("C-KEYMAP-COMMENT-UP: arrow keys are no-op in comment editor", () => {
269
+ const c = openComment();
270
+ c.handleInput("a");
271
+ c.handleInput(UP);
272
+ c.handleInput(RIGHT);
273
+ c.handleInput("b");
274
+ // comment editor renders text and cursor on separate lines;
275
+ // check the text line (before cursor) contains "ab"
276
+ const lines = c.render(60);
277
+ const textLine = lines.find((l) => l.includes("ab"));
278
+ expect(textLine).toBeDefined();
279
+ expect(textLine).toContain("ab");
280
+ // Ensure no leaked bracket characters from arrow escape sequences
281
+ const allText = lines.join("\n");
282
+ expect(allText).not.toMatch(/\[A/); // no leaked UP sequence
283
+ expect(allText).not.toMatch(/\[C/); // no leaked RIGHT sequence
284
+ });
285
+
258
286
  // ── C-KEYMAP-MOD: modifier key combinations matrix (18 cases) ──
259
287
  const modifierCases: Array<{ name: string; seq: string }> = [
260
288
  { name: "ctrl+up", seq: CTRL_UP },
@@ -401,6 +429,18 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
401
429
  expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
402
430
  });
403
431
 
432
+ it("C-CSI-10: unknown CSI does not leak in comment editor", () => {
433
+ const c = openComment();
434
+ c.handleInput(UNKNOWN_CSI);
435
+ c.handleInput("ab");
436
+ const lines = c.render(60);
437
+ const textLine = lines.find((l) => l.includes("ab"));
438
+ expect(textLine).toBeDefined();
439
+ expect(textLine).toContain("ab");
440
+ const allText = lines.join("\n");
441
+ expect(allText).not.toMatch(/\[9/);
442
+ });
443
+
404
444
  it("C-CSI-R1: plain text still appended correctly", () => {
405
445
  const c = openFreeform([singleQ]);
406
446
  c.handleInput("hello");
@@ -11,9 +11,11 @@ import {
11
11
  LEFT,
12
12
  mockTui,
13
13
  multiQ,
14
+ multiQWithComment,
14
15
  RIGHT,
15
16
  singleQ,
16
17
  singleQMulti,
18
+ singleQWithComment,
17
19
  stubTheme,
18
20
  TAB,
19
21
  UP,
@@ -161,20 +163,24 @@ describe("AskUserComponent — multi question tab nav", () => {
161
163
  const { c, result } = make([singleQMulti]);
162
164
  // singleQMulti: [Auth, Search],光标初始在 Auth(0)
163
165
  c.handleInput(DOWN); // 光标移到 Search(1),未 toggle
164
- c.handleInput(ENTER); // Enter 应同时选中 Search + confirm → submit(单问题)
166
+ c.handleInput(ENTER); // Enter 应同时选中 Search + confirm + allowComment comment
167
+ // 断言进入了评论模式(说明 Enter 确认了,而非 no-op)
168
+ const lines = c.render(60);
169
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
170
+ c.handleInput(ENTER); // 跳过评论 → submit(单问题)
165
171
  expect(result.val!.answers["Which features?"]).toBe("Search");
166
172
  });
167
173
 
168
- it("C-S3: auto-confirm(←/→ 切 tab)已答问题自动确认", () => {
169
- // S-3 锁定:←/→ 切走时已答问题 auto-confirm
174
+ it("C-S3: auto-confirm(←/→ 切 tab)跳过评论输入行,仅 Enter 路径才进评论", () => {
175
+ // S-3 锁定:allowComment 的问题,←/→ 切走只 auto-confirm,不进评论模式
170
176
  const twoQMulti: Question[] = [
171
- { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
177
+ { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true, allowComment: true },
172
178
  { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
173
179
  ];
174
180
  const { c, result } = make(twoQMulti);
175
181
  c.handleInput(" "); // Q1 toggle A
176
- c.handleInput(RIGHT); // → Q2,auto-confirm Q1
177
- // 验证:当前在 Q2Q2 选 X → Submit
182
+ c.handleInput(RIGHT); // → Q2,auto-confirm Q1,不进评论
183
+ // 验证:当前在 Q2(非 Q1 的评论模式)。Q2 选 X → Submit
178
184
  c.handleInput(ENTER); // Q2 select X → Submit
179
185
  c.handleInput(ENTER); // Submit
180
186
  expect(result.val!.answers["Q1"]).toBe("A"); // auto-confirm 生效
@@ -262,14 +268,13 @@ describe("AskUserComponent — multi-select toggle", () => {
262
268
  expect(lines.some((l) => l.includes("[ ]") && l.includes("Auth"))).toBe(true);
263
269
  });
264
270
 
265
- it("C-24 (AC-18): multi-select toggle stays in options mode (no editor)", () => {
271
+ it("C-24 (AC-18): multi-select toggle does NOT trigger comment mode", () => {
272
+ // singleQMulti has allowComment:true + multiSelect:true
266
273
  const { c } = make([singleQMulti]);
267
- c.handleInput(" "); // toggle — stays in options mode
274
+ c.handleInput(" "); // toggle — should NOT enter comment mode
268
275
  const lines = c.render(60);
269
- // 未进入编辑器模式(无反色光标行)
270
- expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
271
- // toggle 状态可见
272
- expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
276
+ // No comment prompt shown
277
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(false);
273
278
  });
274
279
  });
275
280
 
@@ -467,6 +472,108 @@ describe("AskUserComponent — multi-char paste in editor", () => {
467
472
  });
468
473
  });
469
474
 
475
+ // ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
476
+ describe("AskUserComponent — comment flow", () => {
477
+ it("C-33: single-select + allowComment Enter enters comment mode", () => {
478
+ const { c } = make([singleQWithComment]);
479
+ c.handleInput(ENTER); // select Postgres
480
+ const lines = c.render(60);
481
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
482
+ });
483
+
484
+ it("C-34 (AC-12): comment Enter empty skips and submits (single)", () => {
485
+ const { c, result } = make([singleQWithComment]);
486
+ c.handleInput(ENTER); // select → comment mode
487
+ c.handleInput(ENTER); // empty comment → skip → submit
488
+ expect(result.val).not.toBeUndefined();
489
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
490
+ });
491
+
492
+ it("C-35: comment Enter with text saves comment", () => {
493
+ const { c, result } = make([singleQWithComment]);
494
+ c.handleInput(ENTER); // select → comment mode
495
+ c.handleInput("f");
496
+ c.handleInput("a");
497
+ c.handleInput("s");
498
+ c.handleInput("t");
499
+ c.handleInput(ENTER); // save comment → submit
500
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — fast");
501
+ });
502
+
503
+ it("C-38: multi-select + allowComment: Enter after toggle enters comment", () => {
504
+ const { c } = make([singleQMulti]);
505
+ c.handleInput(" "); // toggle Auth
506
+ c.handleInput(ENTER); // confirm → comment mode
507
+ const lines = c.render(60);
508
+ expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
509
+ });
510
+
511
+ it("C-39: Other + allowComment: freeText then comment", () => {
512
+ const { c, result } = make([singleQWithComment]);
513
+ // Navigate to Other
514
+ c.handleInput(DOWN);
515
+ c.handleInput(DOWN);
516
+ c.handleInput(ENTER); // open freeform
517
+ c.handleInput("c");
518
+ c.handleInput("u");
519
+ c.handleInput("s");
520
+ c.handleInput("t");
521
+ c.handleInput("o");
522
+ c.handleInput("m");
523
+ c.handleInput(ENTER); // save freeText → allowComment → comment mode
524
+ c.handleInput(ENTER); // empty comment → skip → submit
525
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("custom");
526
+ });
527
+
528
+ it("C-36 (AC-17): comment Esc skips comment and advances (single)", () => {
529
+ const { c, result } = make([singleQWithComment]);
530
+ c.handleInput(ENTER); // select Postgres → comment mode
531
+ c.handleInput(ESC); // Esc in comment = skip comment → advance → submit
532
+ expect(result.val).not.toBeUndefined();
533
+ // commentValue stays null (no prior comment), answer is the selected option
534
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
535
+ });
536
+
537
+ it("C-36b (AC-17): comment Esc advances to next tab (multi-question)", () => {
538
+ const { c, result } = make(multiQWithComment);
539
+ // Q1 (allowComment): select A → comment mode
540
+ c.handleInput(ENTER); // select A → comment mode
541
+ c.handleInput(ESC); // Esc in comment = skip → advance to Q2
542
+ // Q2: select X → Submit
543
+ c.handleInput(ENTER); // select X → Submit
544
+ c.handleInput(ENTER); // Submit
545
+ expect(result.val).not.toBeUndefined();
546
+ expect(result.val!.answers["Q1"]).toBe("A");
547
+ expect(result.val!.answers["Q2"]).toBe("X");
548
+ });
549
+
550
+ it("C-36c (AC-17): Esc-in-comment discards typed text (vs Enter which saves)", () => {
551
+ // Contrast: typing then Enter would save commentValue and append " — keep".
552
+ // Esc should discard the typed editor text and advance without attaching it.
553
+ const { c, result } = make([singleQWithComment]);
554
+ c.handleInput(ENTER); // select Postgres → comment mode
555
+ c.handleInput("k");
556
+ c.handleInput("e");
557
+ c.handleInput("e");
558
+ c.handleInput("p");
559
+ c.handleInput(ESC); // Esc in comment = discard typed text → advance → submit
560
+ expect(result.val).not.toBeUndefined();
561
+ // No " — keep" suffix: Esc did not commit the typed text
562
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
563
+ });
564
+
565
+ it("C-37: answer + comment combined format 'label — note'", () => {
566
+ const { c, result } = make([singleQWithComment]);
567
+ c.handleInput(ENTER); // select Postgres → comment mode
568
+ c.handleInput("n");
569
+ c.handleInput("o");
570
+ c.handleInput("t");
571
+ c.handleInput("e");
572
+ c.handleInput(ENTER); // save comment → submit
573
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — note");
574
+ });
575
+ });
576
+
470
577
  // ── 5g. 防重入(FR-12)─────────────────────────────────
471
578
  describe("AskUserComponent — re-entry guard", () => {
472
579
  it("C-40: ignores input after resolution (submit)", () => {
@@ -551,12 +658,12 @@ describe("AskUserComponent — Submit tab", () => {
551
658
  });
552
659
 
553
660
  it("C-46: Submit Enter when all confirmed submits", () => {
554
- const { c, result } = make([
555
- { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
556
- { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
557
- ]);
558
- c.handleInput(ENTER); // Q1 select A → Q2
559
- c.handleInput(ENTER); // Q2 select X → Submit
661
+ const { c, result } = make(multiQWithComment);
662
+ // Q1 (allowComment): select A comment mode skip
663
+ c.handleInput(ENTER); // select A
664
+ c.handleInput(ENTER); // skip comment → Q2
665
+ // Q2: select X
666
+ c.handleInput(ENTER); // → Submit
560
667
  c.handleInput(ENTER); // Submit
561
668
  expect(result.val).not.toBeUndefined();
562
669
  expect(result.val!.answers["Q1"]).toBe("A");
@@ -699,7 +806,7 @@ describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () =>
699
806
  // ── 5l. 新行为:←/→ 不切 tab、Other Enter 切 freeform 原生、Submit tab focus ──
700
807
  describe("AskUserComponent — new behavior (post-refactor)", () => {
701
808
  it("C-NEW-1: multi-select Other + Enter opens freeform; Other row turns into [ ] <input>█ in-place", () => {
702
- // singleQMulti: [Auth, Search],多选
809
+ // singleQMulti: [Auth, Search],多选 + allowComment
703
810
  const { c, result } = make([singleQMulti]);
704
811
  // 1) Space toggle Auth
705
812
  c.handleInput(" ");
@@ -720,13 +827,14 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
720
827
  expect(lines.some((l) => l.includes("Search"))).toBe(true);
721
828
  // [✓] 标记的 Auth 仍存在(toggle 状态保留)
722
829
  expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
723
- // 4) 输 "redis" → Enter 保存 → submit(单问题)
830
+ // 4) 输 "redis" → Enter 保存 → allowComment → comment mode
724
831
  c.handleInput("r");
725
832
  c.handleInput("e");
726
833
  c.handleInput("d");
727
834
  c.handleInput("i");
728
835
  c.handleInput("s");
729
- c.handleInput(ENTER); // 保存 freeText → submit(单问题)
836
+ c.handleInput(ENTER); // 保存 freeText → comment mode
837
+ c.handleInput(ENTER); // 跳过评论 → submit(单问题)
730
838
  // 答案含多选 toggle 项 + Other 自定义
731
839
  expect(result.val!.answers["Which features?"]).toBe("Auth, redis");
732
840
  });
@@ -769,12 +877,11 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
769
877
  });
770
878
 
771
879
  it("C-NEW-4: Submit tab Enter on Submit focus (all confirmed) submits", () => {
772
- const { c, result } = make([
773
- { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
774
- { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
775
- ]);
880
+ // multiQWithComment: Q1 allowComment, Q2 plain
881
+ const { c, result } = make(multiQWithComment);
776
882
  // 答完 Q1 + Q2
777
- c.handleInput(ENTER); // Q1 select A → Q2
883
+ c.handleInput(ENTER); // Q1 select A → comment mode
884
+ c.handleInput(ENTER); // skip comment → Q2
778
885
  c.handleInput(ENTER); // Q2 select X → Submit tab(Q2 是最后一个问题,advance 到 Submit)
779
886
  // 已经在 Submit tab,focus=Submit,按 Enter 提交
780
887
  c.handleInput(ENTER);
@@ -7,8 +7,8 @@ import { describe, expect, it } from "vitest";
7
7
 
8
8
  import { makeE2E } from "./e2e-harness";
9
9
 
10
- // ── E2E-1: 单问题 — 选第二项提交 ────────────────────
11
- describe("E2E-1: single question — pick 2nd option", () => {
10
+ // ── E2E-1: 单问题无评论 — 选第二项提交 ─────────────────
11
+ describe("E2E-1: single question, no comment — pick 2nd option", () => {
12
12
  const questions = [
13
13
  {
14
14
  question: "Which DB?",
@@ -32,6 +32,53 @@ describe("E2E-1: single question — pick 2nd option", () => {
32
32
  });
33
33
  });
34
34
 
35
+ // ── E2E-2: 单问题 + allowComment — 选项 + 评论拼接 ─────
36
+ describe("E2E-2: single question + allowComment — option + comment joined", () => {
37
+ const questions = [
38
+ {
39
+ question: "Which DB?",
40
+ allowComment: true,
41
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
42
+ },
43
+ ];
44
+
45
+ it("joins selected option with comment via ' — '", async () => {
46
+ const e = makeE2E(questions);
47
+ // Enter 选 Postgres → 进评论模式 → 输 "fast" → Enter 保存(allowComment 分支)
48
+ e.keys(["\r", "f", "a", "s", "t", "\r"]);
49
+ const result = await e.getExecuted();
50
+ const details = result.details;
51
+
52
+ expect(details.cancelled).toBe(false);
53
+ expect(details.answers["Which DB?"]).toBe("Postgres — fast");
54
+ expect(result.content[0].text).toContain("Postgres — fast");
55
+ });
56
+ });
57
+
58
+ // ── E2E-3: 单问题 + allowComment — Enter 空评论跳过 ─────
59
+ describe("E2E-3: single question + allowComment — Enter in comment skips", () => {
60
+ const questions = [
61
+ {
62
+ question: "Which DB?",
63
+ allowComment: true,
64
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
65
+ },
66
+ ];
67
+
68
+ it("empty Enter in comment mode keeps option without ' — ' suffix", async () => {
69
+ const e = makeE2E(questions);
70
+ // Enter 选 Postgres → 直接 Enter 评论模式跳过(AC-12)
71
+ e.keys(["\r", "\r"]);
72
+ const result = await e.getExecuted();
73
+ const details = result.details;
74
+
75
+ expect(details.cancelled).toBe(false);
76
+ // 不含 " — " 分隔符
77
+ expect(details.answers["Which DB?"]).toBe("Postgres");
78
+ expect(details.answers["Which DB?"]).not.toContain("—");
79
+ });
80
+ });
81
+
35
82
  // ── E2E-4: 多问题提交 — 逐题选择后 Submit tab 提交(S-11)──────
36
83
  describe("E2E-4: multi-question submit — answer each then Submit tab", () => {
37
84
  const questions = [
@@ -42,6 +42,7 @@ export const singleQ: Question = {
42
42
 
43
43
  export const singleQWithComment: Question = {
44
44
  question: "Which DB? (with comment)",
45
+ allowComment: true,
45
46
  options: [
46
47
  { label: "Postgres", description: "Battle-tested" },
47
48
  { label: "SQLite", description: "Embedded" },
@@ -51,6 +52,7 @@ export const singleQWithComment: Question = {
51
52
  export const singleQMulti: Question = {
52
53
  question: "Which features?",
53
54
  multiSelect: true,
55
+ allowComment: true,
54
56
  options: [
55
57
  { label: "Auth", description: "OAuth + session" },
56
58
  { label: "Search", description: "Full-text" },
@@ -69,7 +71,7 @@ export const multiQ: Question[] = [
69
71
  ];
70
72
 
71
73
  export const multiQWithComment: Question[] = [
72
- { question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
74
+ { question: "Q1", header: "First", allowComment: true, options: [{ label: "A" }, { label: "B" }] },
73
75
  { question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
74
76
  ];
75
77
 
@@ -645,6 +645,31 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
645
645
  expect(result.details.answers["Which DB?"]).toBe("Postgres, Custom DB");
646
646
  });
647
647
 
648
+ it("R-4: comment → inlined with ' — ' separator", async () => {
649
+ const tool = getTool();
650
+ const withComment = {
651
+ questions: [
652
+ {
653
+ question: "Which DB?",
654
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
655
+ allowComment: true,
656
+ },
657
+ ],
658
+ };
659
+ const protoAnswers = JSON.stringify({
660
+ "Which DB?": "Postgres",
661
+ "Which DB?__comment": "prod constraint",
662
+ });
663
+ const result = await tool.execute(
664
+ "id",
665
+ withComment,
666
+ undefined,
667
+ undefined,
668
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
669
+ );
670
+ expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
671
+ });
672
+
648
673
  it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
649
674
  const tool = getTool();
650
675
  const result = await tool.execute(
@@ -696,7 +721,7 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
696
721
  expect(result.details.answers["Which database?"]).toBe("Postgres");
697
722
  });
698
723
 
699
- it("R-8: multi-question mixed (single-select + multi-select + Other)", async () => {
724
+ it("R-8: multi-question mixed (single-select + multi-select + Other + comment)", async () => {
700
725
  const tool = getTool();
701
726
  const mixed = {
702
727
  questions: [
@@ -715,6 +740,7 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
715
740
  question: "Which region?",
716
741
  header: "Region",
717
742
  options: [{ label: "US" }, { label: "EU" }],
743
+ allowComment: true,
718
744
  },
719
745
  ],
720
746
  };
@@ -743,4 +769,40 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
743
769
  // Q3: 无选中 → 跳过(不在 answers map 中)
744
770
  expect(result.details.answers["Which region?"]).toBeUndefined();
745
771
  });
772
+
773
+ it("R-9: multi-question with comment on one question", async () => {
774
+ const tool = getTool();
775
+ const multiQ = {
776
+ questions: [
777
+ {
778
+ question: "Which DB?",
779
+ header: "DB",
780
+ options: [{ label: "Postgres" }],
781
+ },
782
+ {
783
+ question: "Why?",
784
+ header: "Reason",
785
+ options: [{ label: "Performance" }],
786
+ allowComment: true,
787
+ },
788
+ ],
789
+ };
790
+ const protoAnswers = JSON.stringify({
791
+ DB: "Postgres",
792
+ Reason: "Performance",
793
+ "Reason__comment": "benchmarked",
794
+ });
795
+ const result = await tool.execute(
796
+ "id",
797
+ multiQ,
798
+ undefined,
799
+ undefined,
800
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
801
+ );
802
+
803
+ // Q1: 无 comment
804
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
805
+ // Q2: 有 comment → 内联
806
+ expect(result.details.answers["Why?"]).toBe("Performance — benchmarked");
807
+ });
746
808
  });
@@ -252,7 +252,7 @@ describe("renderQuestionView — Other editor mode", () => {
252
252
  });
253
253
 
254
254
  it("Q-28-WIDE: freeform 模式在宽终端下用全宽渲染(不被分屏左列压窄)", () => {
255
- // 回归:freeform 模式忽略分屏,编辑器用全 width。
255
+ // 回归:freeform/comment 模式忽略分屏,编辑器用全 width。
256
256
  // 修复前:宽终端走 split.left(≈40),Other 输入被压在左半屏换行频繁。
257
257
  const width = 100;
258
258
  // getSplitPaneWidths(100) 非 null(宽终端会进分屏分支),但 freeform 应绕过它
@@ -335,6 +335,35 @@ describe("renderQuestionView — Other editor mode", () => {
335
335
  });
336
336
  });
337
337
 
338
+ // ── Q-18 ~ Q-19: 评论模式 ───────────────────────────────
339
+ describe("renderQuestionView — comment mode", () => {
340
+ it("Q-18: comment mode renders editor with note text", () => {
341
+ const lines = rv(
342
+ singleQ,
343
+ makeState({ mode: "comment", selectedIndex: 0 }),
344
+ stubTheme,
345
+ 60,
346
+ true,
347
+ "my note",
348
+ );
349
+ const t = text(lines);
350
+ expect(t.toLowerCase()).toContain("comment");
351
+ expect(t).toContain("my note");
352
+ });
353
+
354
+ it("Q-19: comment prompt includes (optional)", () => {
355
+ const lines = rv(
356
+ singleQ,
357
+ makeState({ mode: "comment", selectedIndex: 0 }),
358
+ stubTheme,
359
+ 60,
360
+ true,
361
+ "",
362
+ );
363
+ expect(text(lines)).toContain("(optional)");
364
+ });
365
+ });
366
+
338
367
  // ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
339
368
  describe("renderQuestionView — help line", () => {
340
369
  it("Q-20: single-select help shows 'Enter select'", () => {
@@ -124,6 +124,11 @@ describe("getAnswerText", () => {
124
124
  expect(getAnswerText(q1, s)).toBe("custom");
125
125
  });
126
126
 
127
+ it("S-9: comment appended with separator", () => {
128
+ const s = makeState({ confirmed: true, selectedIndex: 0, commentValue: "fast" });
129
+ expect(getAnswerText(q1, s)).toBe("Postgres — fast");
130
+ });
131
+
127
132
  it("S-10: unconfirmed returns null", () => {
128
133
  const s = makeState({ confirmed: false });
129
134
  expect(getAnswerText(q1, s)).toBeNull();
@@ -143,6 +148,21 @@ describe("getAnswerText", () => {
143
148
  const sEmptyMulti = makeState({ confirmed: true, selectedIndices: new Set<number>() });
144
149
  expect(getAnswerText(multiQ, sEmptyMulti)).toBeNull();
145
150
  });
151
+
152
+ it("S-9b: multi-select + comment combined", () => {
153
+ const multiQ: Question = {
154
+ question: "Features",
155
+ multiSelect: true,
156
+ allowComment: true,
157
+ options: [{ label: "A" }, { label: "B" }],
158
+ };
159
+ const s = makeState({
160
+ confirmed: true,
161
+ selectedIndices: new Set([0, 1]),
162
+ commentValue: "nice",
163
+ });
164
+ expect(getAnswerText(multiQ, s)).toBe("A, B — nice");
165
+ });
146
166
  });
147
167
 
148
168
  // ── S-11 ~ S-12: buildResult ─────────────────────────────
@@ -40,6 +40,7 @@ describe("types", () => {
40
40
  expect(s.selectedIndices).toBeInstanceOf(Set);
41
41
  expect(s.confirmed).toBe(false);
42
42
  expect(s.freeTextValue).toBeNull();
43
+ expect(s.commentValue).toBeNull();
43
44
  expect(s.mode).toBe("options");
44
45
  });
45
46
 
@@ -82,7 +82,7 @@ describe("validateInput", () => {
82
82
  });
83
83
 
84
84
  // V-10/S3: 多 question 时 header 必须唯一——重复 header 会导致 askUserKey 碰撞
85
- // (协议 helper 用 header 作 answers 读取 key,后一个覆盖前一个的 Other)
85
+ // (协议 helper 用 header 作 answers 读取 key,后一个覆盖前一个的 Other/comment
86
86
  it("rejects duplicate headers across different questions", () => {
87
87
  const result = validateInput([
88
88
  q({ question: "Q1", header: "Same" }),
@@ -13,6 +13,7 @@ import {
13
13
  multiQ,
14
14
  RIGHT,
15
15
  singleQ,
16
+ singleQWithComment,
16
17
  stubTheme,
17
18
  UP,
18
19
  } from "./fixtures";
@@ -77,6 +78,15 @@ describe("W2 — draftText migration", () => {
77
78
  expect(editorLine1).toContain("aaa");
78
79
  expect(editorLine1).not.toContain("ccc");
79
80
  });
81
+
82
+ it("C-BC4C: comment flow submits comment with answer", () => {
83
+ const { c, result } = make([singleQWithComment]);
84
+ c.handleInput(ENTER); // select A → comment mode
85
+ c.handleInput("my note");
86
+ c.handleInput(ENTER); // submit
87
+ expect(result.val).toBeDefined();
88
+ expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — my note");
89
+ });
80
90
  });
81
91
 
82
92
  // ── hint line: append-only UX hint ──
@@ -92,6 +102,16 @@ describe("W2 — hint line", () => {
92
102
  expect(hintLine).toContain("Enter submit");
93
103
  expect(hintLine).toContain("Esc back");
94
104
  });
105
+
106
+ it("C-HINT-2: comment editor hint contains all expected hints", () => {
107
+ const { c } = make([singleQWithComment]);
108
+ c.handleInput(ENTER);
109
+ const lines = c.render(80);
110
+ const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
111
+ expect(hintLine).toBeDefined();
112
+ expect(hintLine).toContain("Enter submit");
113
+ expect(hintLine).toContain("Esc back");
114
+ });
95
115
  });
96
116
 
97
117
  // ── freeDraft 隔离:丢弃的 freeform 草稿不污染答案、不触发 auto-confirm ──
@@ -1,5 +1,5 @@
1
1
  // src/__tests__/w3-regression.test.ts
2
- // W3: Forward regression — freeform/bksp edge cases.
2
+ // W3: Forward regression — freeform/comment/bksp edge cases.
3
3
  // No-op keymap coverage moved to component-keymap.test.ts (deduplicated).
4
4
  import { describe, expect, it } from "vitest";
5
5
 
@@ -14,6 +14,7 @@ import {
14
14
  LEFT,
15
15
  mockTui,
16
16
  multiQ,
17
+ multiQWithComment,
17
18
  RIGHT,
18
19
  singleQ,
19
20
  stubTheme,
@@ -71,6 +72,33 @@ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
71
72
  });
72
73
  });
73
74
 
75
+ // ── C-BC4C: comment edge cases ──
76
+ // C-BC4C-REEDIT (initial comment submit) removed — duplicate of w2-draft-hint C-BC4C.
77
+ describe("W3 — comment re-edit (C-BC4C-CLEAR)", () => {
78
+ it("C-BC4C-CLEAR: Esc in comment mode skips comment, keeps existing commentValue", () => {
79
+ const { c, result } = make(multiQWithComment);
80
+ // Q1: select A → comment mode
81
+ c.handleInput(ENTER);
82
+ c.handleInput("my note");
83
+ c.handleInput(ENTER); // submit comment → advance to Q2
84
+ // Q2: select X
85
+ c.handleInput(ENTER);
86
+ c.handleInput(ENTER); // confirm Q2 → advance to submit tab
87
+ // Go back to Q1
88
+ c.handleInput(LEFT);
89
+ // Q1 is already confirmed, re-select A to trigger comment again
90
+ c.handleInput(ENTER); // re-select A → afterConfirm → comment mode
91
+ c.handleInput(ESC); // skip comment
92
+ // Submit
93
+ c.handleInput(RIGHT);
94
+ c.handleInput(RIGHT); // navigate to submit tab
95
+ c.handleInput(ENTER);
96
+ expect(result.val).toBeDefined();
97
+ // Comment should still be "my note" (Esc preserved commentValue)
98
+ expect(result.val!.answers["Q1"]).toBe("A — my note");
99
+ });
100
+ });
101
+
74
102
  // ── C-BKSP-EDGE: backspace at cursorIndex=0 ──
75
103
 
76
104
  describe("W3 — backspace at cursorIndex=0 (C-BKSP-EDGE)", () => {
@@ -1,16 +1,19 @@
1
1
  // src/answer-format.ts
2
2
  // 答案文本格式的唯一权威模块。
3
3
  // TUI 路径(submit-view.ts:getAnswerText)和 RPC 路径(index.ts:protoAnswersToResult)
4
- // 都调 formatAnswer 产出 "label1, label2" 格式,确保两条路径一致。
4
+ // 都调 formatAnswer 产出 "label1, label2 — comment" 格式,确保两条路径一致。
5
5
  // renderExpandedOptions(index.ts)调 parseAnswerParts 精确反解析选中项。
6
+ import { ANSWER_COMMENT_SEPARATOR } from "./types";
6
7
 
7
8
  /**
8
- * 把答案各部分拼装为最终文本格式:"part1, part2"。
9
+ * 把答案各部分拼装为最终文本格式:"part1, part2 — comment"。
9
10
  * - parts 为空 → 返回 null(未答)
11
+ * - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
10
12
  */
11
- export function formatAnswer(parts: string[]): string | null {
13
+ export function formatAnswer(parts: string[], comment?: string | null): string | null {
12
14
  if (parts.length === 0) return null;
13
- return parts.join(", ");
15
+ const base = parts.join(", ");
16
+ return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
14
17
  }
15
18
 
16
19
  /**
@@ -19,15 +22,24 @@ export function formatAnswer(parts: string[]): string | null {
19
22
  *
20
23
  * @param answer 最终答案文本(formatAnswer 产出)
21
24
  * @param labels 候选 label 列表(q.options 的 label),精确匹配
22
- * @returns selected=命中的 labels(按 answer 中出现顺序)
25
+ * @returns selected=命中的 labels(按 answer 中出现顺序),comment=评论文本(如有)
23
26
  */
24
27
  export function parseAnswerParts(
25
28
  answer: string,
26
29
  labels: string[],
27
- ): { selected: string[] } {
28
- // answer 形如 "label1, label2" → 精确匹配候选 label
30
+ ): { selected: string[]; comment?: string } {
31
+ // 先提取 comment(ANSWER_COMMENT_SEPARATOR 之后的部分)
32
+ let body = answer;
33
+ let comment: string | undefined;
34
+ const sepIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
35
+ if (sepIdx >= 0) {
36
+ body = answer.slice(0, sepIdx);
37
+ comment = answer.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim();
38
+ }
39
+
40
+ // body 形如 "label1, label2" → 精确匹配候选 label
29
41
  const labelSet = new Set(labels);
30
- const tokens = answer.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
42
+ const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
31
43
  const selected: string[] = [];
32
44
  // 剩余 tokens 不匹配任何 label → 是 Other 自由文本(不返回,调用方自行处理)
33
45
  for (const token of tokens) {
@@ -35,5 +47,5 @@ export function parseAnswerParts(
35
47
  selected.push(token);
36
48
  }
37
49
  }
38
- return { selected };
50
+ return { selected, comment };
39
51
  }
@@ -9,8 +9,8 @@
9
9
  // 不循环)。返回 {value: JSON.stringify(answers)} 让子进程 JSON.parse(value) decode。
10
10
  // - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) protoQuestions → 内部 Question[],
11
11
  // (2) ctx.ui.custom 渲染拿内部 Result,(3) 内部 Result.answers(key=question 全文,
12
- // value="label1, label2")→ 重新编码为 proto AskUserAnswers(key=header/question,
13
- // 单选=value,多选=JSON 数组,Other→__other),让子进程 decode 一致。
12
+ // value="label1, label2 — comment")→ 重新编码为 proto AskUserAnswers(key=header/question,
13
+ // 单选=value,多选=JSON 数组,Other→__other,comment→__comment),让子进程 decode 一致。
14
14
  //
15
15
  // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
16
  // 由子进程 askUserInteract 编码、subagent-workflow parseChannel 解析 options[0] JSON 得到)。
@@ -23,7 +23,7 @@ import {
23
23
  } from "@xyz-agent/extension-protocol";
24
24
 
25
25
  import { AskUserComponent } from "./component";
26
- import { type Option, type Question, type Result, type ThemeLike } from "./types";
26
+ import { ANSWER_COMMENT_SEPARATOR, type Option, type Question, type Result, type ThemeLike } from "./types";
27
27
 
28
28
  /**
29
29
  * channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
@@ -59,6 +59,7 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
59
59
  ...(pq.context !== undefined ? { context: pq.context } : {}),
60
60
  options: opts,
61
61
  ...(pq.multiSelect !== undefined ? { multiSelect: pq.multiSelect } : {}),
62
+ ...(pq.allowComment !== undefined ? { allowComment: pq.allowComment } : {}),
62
63
  };
63
64
  });
64
65
  }
@@ -66,17 +67,18 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
66
67
  /**
67
68
  * 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
68
69
  *
69
- * 内部 Result.answers:key = question 全文,value = "label1, label2"
70
- * (Other 自由文本与 selected 标签逗号拼接)。
70
+ * 内部 Result.answers:key = question 全文,value = "label1, label2 — comment"
71
+ * (Other 自由文本与 selected 标签逗号拼接,comment 用 ANSWER_COMMENT_SEPARATOR 分隔)。
71
72
  *
72
73
  * proto AskUserAnswers 契约(@xyz-agent/extension-protocol):
73
74
  * - key = question.header ?? question 全文
74
75
  * - 单选:value = 选中项 value string
75
76
  * - 多选:value = JSON.stringify(选中项 value 数组)
76
77
  * - Other 自由文本:单独 key `${header}__other`
78
+ * - comment:单独 key `${header}__comment`
77
79
  *
78
80
  * 解码(无信息丢失):用 protoQuestion.options 的 label 集合精确匹配 selected;
79
- * 不匹配的 token = Other 自由文本。
81
+ * 不匹配的 token = Other 自由文本;comment 由 ANSWER_COMMENT_SEPARATOR 切出。
80
82
  */
81
83
  function encodeTuiResultToProto(
82
84
  protoQuestions: AskUserQuestion[],
@@ -95,8 +97,15 @@ function encodeTuiResultToProto(
95
97
  if (o.value !== undefined) knownLabels.add(o.value);
96
98
  }
97
99
 
100
+ // 切 body / comment(comment 在 ANSWER_COMMENT_SEPARATOR 之后)
101
+ const sepIdx = internalText.indexOf(ANSWER_COMMENT_SEPARATOR);
102
+ const body = sepIdx >= 0 ? internalText.slice(0, sepIdx) : internalText;
103
+ const comment = sepIdx >= 0
104
+ ? internalText.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim() || undefined
105
+ : undefined;
106
+
98
107
  // body tokens:匹配 knownLabels 的为 selected,其余为 Other 自由文本
99
- const tokens = internalText.split(/[,,]/).map((t: string) => t.trim()).filter((t: string) => t !== "");
108
+ const tokens = body.split(/[,,]/).map((t: string) => t.trim()).filter((t: string) => t !== "");
100
109
  const selected: string[] = [];
101
110
  const otherTokens: string[] = [];
102
111
  for (const t of tokens) {
@@ -121,6 +130,7 @@ function encodeTuiResultToProto(
121
130
  }
122
131
 
123
132
  if (otherText) answers[`${key}__other`] = otherText;
133
+ if (comment) answers[`${key}__comment`] = comment;
124
134
  }
125
135
  return answers;
126
136
  }
package/src/component.ts CHANGED
@@ -201,8 +201,8 @@ export class AskUserComponent implements Component {
201
201
  const state = this.states[this.activeTab]!;
202
202
  const q = this.questions[this.activeTab]!;
203
203
 
204
- // freeform mode → editor text input
205
- if (state.mode === "freeform") {
204
+ // freeform / comment mode → editor text input
205
+ if (state.mode === "freeform" || state.mode === "comment") {
206
206
  this.handleEditorInput(data, state, q);
207
207
  return;
208
208
  }
@@ -264,14 +264,14 @@ export class AskUserComponent implements Component {
264
264
  }
265
265
  if (matchesKey(data, "enter")) {
266
266
  state.selectedIndices.add(state.cursorIndex);
267
- this.afterConfirm(state);
267
+ this.afterConfirm(state, q);
268
268
  return;
269
269
  }
270
270
  } else if (!q.multiSelect && !onOther) {
271
271
  if (matchesKey(data, "enter")) {
272
272
  state.selectedIndex = state.cursorIndex;
273
273
  state.freeTextValue = null;
274
- this.afterConfirm(state);
274
+ this.afterConfirm(state, q);
275
275
  return;
276
276
  }
277
277
  }
@@ -364,8 +364,16 @@ export class AskUserComponent implements Component {
364
364
  // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
365
365
  }
366
366
 
367
- /** Esc:freeform 存 freeDraft 草稿后回 options。 */
367
+ /** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
368
368
  private handleEditorEsc(state: QuestionState): void {
369
+ if (state.mode === "comment") {
370
+ // comment Esc: skip comment, advance (keep existing commentValue)
371
+ state.mode = "options";
372
+ state.draftText = "";
373
+ state.cursorIndex = state.savedOptionsCursorIndex;
374
+ this.advance();
375
+ return;
376
+ }
369
377
  // freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
370
378
  // so discarded drafts don't pollute the answer or trigger auto-confirm.
371
379
  state.freeDraft = state.draftText || null;
@@ -375,26 +383,34 @@ export class AskUserComponent implements Component {
375
383
  this.rerender();
376
384
  }
377
385
 
378
- /** Enter:freeform 有文本→提交,空文本→回退。 */
386
+ /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
379
387
  private handleEditorEnter(state: QuestionState, q: Question): void {
380
388
  const text = state.draftText.trim();
381
- state.cursorIndex = state.savedOptionsCursorIndex;
382
- if (text) {
383
- state.freeTextValue = text;
384
- state.selectedIndex = null;
385
- state.mode = "options";
386
- state.draftText = "";
387
- this.afterConfirm(state);
388
- } else {
389
- state.freeTextValue = null;
390
- state.mode = "options";
391
- state.draftText = "";
392
- // freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
393
- if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
394
- state.confirmed = false;
389
+ if (state.mode === "freeform") {
390
+ state.cursorIndex = state.savedOptionsCursorIndex;
391
+ if (text) {
392
+ state.freeTextValue = text;
393
+ state.selectedIndex = null;
394
+ state.mode = "options";
395
+ state.draftText = "";
396
+ this.afterConfirm(state, q);
397
+ } else {
398
+ state.freeTextValue = null;
399
+ state.mode = "options";
400
+ state.draftText = "";
401
+ // freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
402
+ if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
403
+ state.confirmed = false;
404
+ }
405
+ this.rerender();
395
406
  }
396
- this.rerender();
407
+ return;
397
408
  }
409
+ state.commentValue = text || null;
410
+ state.mode = "options";
411
+ state.draftText = "";
412
+ state.cursorIndex = state.savedOptionsCursorIndex;
413
+ this.advance();
398
414
  }
399
415
 
400
416
  private toggleIndex(state: QuestionState, index: number): void {
@@ -434,9 +450,17 @@ export class AskUserComponent implements Component {
434
450
  this.rerender();
435
451
  }
436
452
 
437
- /** 选中确认后的处理:直接前进到下一题(或单问题提交)。 */
438
- private afterConfirm(state: QuestionState): void {
453
+ /** 选中确认后的处理:若 allowComment(4.0.1 restore 字段),进入评论模式(可重入编辑/清除已有评论);否则前进。 */
454
+ private afterConfirm(state: QuestionState, q: Question): void {
439
455
  state.confirmed = true;
456
+ if (q.allowComment && state.mode !== "comment") {
457
+ state.savedOptionsCursorIndex = state.cursorIndex;
458
+ state.mode = "comment";
459
+ state.draftText = state.commentValue ?? "";
460
+ state.cursorIndex = state.draftText.length;
461
+ this.rerender();
462
+ return;
463
+ }
440
464
  this.advance();
441
465
  }
442
466
 
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  askUserInteract,
8
8
  type AskUserQuestion,
9
9
  getAskUserAnswer,
10
+ getAskUserComment,
10
11
  getAskUserOther,
11
12
  } from "@xyz-agent/extension-protocol";
12
13
 
@@ -127,14 +128,15 @@ function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
127
128
  })),
128
129
  multiSelect: q.multiSelect,
129
130
  allowOther: true,
131
+ allowComment: q.allowComment ?? false,
130
132
  }));
131
133
  }
132
134
 
133
135
  /**
134
136
  * 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
135
137
  *
136
- * 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other
137
- * ask-user 格式:key=question 全文, value=逗号分隔 label + Other
138
+ * 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment(4.0.1 restore)
139
+ * ask-user 格式:key=question 全文, value=逗号分隔 label + Other, comment 内联(` — `)
138
140
  *
139
141
  * 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
140
142
  * 确保 RPC 和 TUI 两条路径产出的 Result.answers 格式一致。
@@ -150,6 +152,7 @@ function protoAnswersToResult(
150
152
  const iq = protoQuestions[i]!;
151
153
  const selected = getAskUserAnswer(answers, iq);
152
154
  const other = getAskUserOther(answers, iq);
155
+ const comment = getAskUserComment(answers, iq);
153
156
 
154
157
  const parts: string[] = [];
155
158
  if (Array.isArray(selected)) {
@@ -167,7 +170,7 @@ function protoAnswersToResult(
167
170
  parts.push(selected);
168
171
  }
169
172
  if (other) parts.push(other);
170
- const formatted = formatAnswer(parts);
173
+ const formatted = formatAnswer(parts, comment);
171
174
  if (formatted !== null) out[q.question] = formatted;
172
175
  }
173
176
  return out;
@@ -136,7 +136,7 @@ function buildOptionLines(
136
136
  for (let i = 0; i < opts.length; i++) {
137
137
  const opt = opts[i]!;
138
138
  // 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
139
- const activeOptionCursor = state.mode === "freeform" ? state.savedOptionsCursorIndex : state.cursorIndex;
139
+ const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
140
140
  const isSelected = i === activeOptionCursor;
141
141
  const isOther = opt.isOther === true;
142
142
  const prefix = isSelected ? t.fg("accent", ">") : " ";
@@ -224,6 +224,33 @@ function buildPreviewLines(
224
224
  return lines;
225
225
  }
226
226
 
227
+ /**
228
+ * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
229
+ * buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
230
+ * comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
231
+ */
232
+ function buildEditorBlock(
233
+ ctx: RenderContext,
234
+ ): string[] {
235
+ const { state, theme: t, width } = ctx;
236
+ if (state.mode === "freeform") {
237
+ return [""];
238
+ }
239
+ const lines: string[] = [];
240
+ const add = (s: string): void => {
241
+ lines.push(truncateToWidth(s, width));
242
+ };
243
+ add("");
244
+ const prompt = t.fg("muted", " Your comment (optional):");
245
+ add(prompt);
246
+ // 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
247
+ const cursorText = renderCursorText(state.draftText, state.cursorIndex);
248
+ add(` ${t.fg("text", cursorText)}`);
249
+ add("");
250
+ add(t.fg("dim", EDITOR_HINT));
251
+ return lines;
252
+ }
253
+
227
254
  /** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
228
255
  function buildSplitPane(
229
256
  ctx: RenderContext,
@@ -275,20 +302,21 @@ export function renderQuestionView(ctx: RenderContext): string[] {
275
302
 
276
303
  // 选项模式下:question/context 与 options 之间加分割线(三段式)
277
304
  // 编辑器模式不加(编辑器块自带视觉边界)
278
- if (state.mode !== "freeform") {
305
+ if (state.mode !== "freeform" && state.mode !== "comment") {
279
306
  divider();
280
307
  }
281
308
 
282
- // 编辑器模式:选项列表 + 原地编辑器行(freeform 模式下 buildOptionLines 原地渲染)。
309
+ // 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines 原地渲染)。
283
310
  // 编辑器模式一律用全 width 单列渲染——分屏左列仅约 42% 宽,Other 自由输入会被压窄换行,
284
311
  // 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
285
- if (state.mode === "freeform") {
312
+ if (state.mode === "freeform" || state.mode === "comment") {
286
313
  add("");
287
314
  for (const line of buildOptionLines(ctx, false)) add(line);
288
- // 选项列表与 help 行之间的视觉空隙(旧 buildEditorBlock freeform 分支返回 [""] 提供)
289
- add("");
290
- // freeform 模式 help 行:光标锁在 Other 上,正在输入
291
- add(t.fg("dim", EDITOR_HINT));
315
+ lines.push(...buildEditorBlock(ctx));
316
+ if (state.mode === "freeform") {
317
+ // freeform 模式 help 行:光标锁在 Other 上,正在输入
318
+ add(t.fg("dim", EDITOR_HINT));
319
+ }
292
320
  return lines;
293
321
  }
294
322
 
@@ -44,7 +44,7 @@ export function getAnswerText(q: Question, s: QuestionState): string | null {
44
44
  if (label) parts.push(label);
45
45
  }
46
46
  if (s.freeTextValue !== null) parts.push(s.freeTextValue);
47
- return formatAnswer(parts);
47
+ return formatAnswer(parts, s.commentValue);
48
48
  }
49
49
 
50
50
  /**
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ export const SPLIT_PANE_MIN_WIDTH = 84;
10
10
  export const SPLIT_PANE_SEPARATOR = " │ ";
11
11
  export const SPLIT_PANE_LEFT_MIN = 32;
12
12
  export const SPLIT_PANE_RIGHT_MIN = 28;
13
+ export const ANSWER_COMMENT_SEPARATOR = " — "; // 答案内联评论分隔符(4.0.1 restore,见 .changeset/restore-ask-user-comment.md)
13
14
 
14
15
  // ── Input schema(LLM 调用参数) ─────────────────────
15
16
  // description 用英文:这些字符串会进 LLM 的 tool schema,英文更利于模型理解。
@@ -46,6 +47,9 @@ export const QuestionSchema = Type.Object({
46
47
  multiSelect: Type.Optional(
47
48
  Type.Boolean({ description: "Default false. Set true only when more than one option can validly apply simultaneously; otherwise leave false for a single best answer." }),
48
49
  ),
50
+ allowComment: Type.Optional(
51
+ Type.Boolean({ description: "Default false. Set true to let the user append a short free-text comment after selecting (e.g. to note a constraint). Restored in 4.0.1." }),
52
+ ),
49
53
  });
50
54
 
51
55
  /**
@@ -119,7 +123,7 @@ export interface ThemeLike {
119
123
  }
120
124
 
121
125
  /** 单问题的交互模式 */
122
- export type QuestionMode = "options" | "freeform";
126
+ export type QuestionMode = "options" | "freeform" | "comment";
123
127
 
124
128
  /** 单问题的交互状态(每问题一个实例) */
125
129
  export interface QuestionState {
@@ -136,6 +140,8 @@ export interface QuestionState {
136
140
  /** freeform Esc 保存的未提交草稿;null=无草稿。
137
141
  * 与 freeTextValue(已提交答案)分离,避免放弃的草稿污染答案、触发 auto-confirm。 */
138
142
  freeDraft: string | null;
143
+ /** 可选评论;null=未输入 */
144
+ commentValue: string | null;
139
145
  /** 当前交互模式 */
140
146
  mode: QuestionMode;
141
147
  /** 编辑器草稿文本(每问题独立持有,进编辑器时预填、退出时清空) */
@@ -153,6 +159,7 @@ export function createQuestionState(): QuestionState {
153
159
  confirmed: false,
154
160
  freeTextValue: null,
155
161
  freeDraft: null,
162
+ commentValue: null,
156
163
  mode: "options",
157
164
  draftText: "",
158
165
  savedOptionsCursorIndex: 0,
package/src/validate.ts CHANGED
@@ -72,12 +72,12 @@ export function validateInput(questions: InputQuestion[]): string | null {
72
72
  }
73
73
 
74
74
  // S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
75
- // 后一个 question 的 __other 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
75
+ // 后一个 question 的 __other/__comment 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
76
76
  const seenHeaders = new Set<string>();
77
77
  for (const q of questions) {
78
78
  const h = q.header!.trim();
79
79
  if (seenHeaders.has(h)) {
80
- return `Duplicate header "${h}" in questions. Headers must be unique in multi-question mode — shared headers cause answer key collisions (one question's Other overwrites another's). Rephrase one header to differ.`;
80
+ return `Duplicate header "${h}" in questions. Headers must be unique in multi-question mode — shared headers cause answer key collisions (one question's Other/comment overwrites another's). Rephrase one header to differ.`;
81
81
  }
82
82
  seenHeaders.add(h);
83
83
  }