@zhushanwen/pi-ask-user 0.1.0 → 0.2.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/src/index.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  // src/index.ts
2
+ import { Box, Text, TruncatedText, truncateToWidth } from "@earendil-works/pi-tui";
2
3
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
3
- import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
4
4
  import { type Static } from "@sinclair/typebox";
5
+ import {
6
+ type AskUserAnswers,
7
+ askUserInteract,
8
+ type AskUserQuestion,
9
+ getAskUserAnswer,
10
+ getAskUserComment,
11
+ getAskUserOther,
12
+ } from "@xyz-agent/extension-protocol";
5
13
 
6
14
  import { AskUserComponent } from "./component";
15
+ import { formatAnswer, parseAnswerParts } from "./answer-format";
7
16
  import {
8
17
  type AskUserDetails,
9
18
  type ErrorDetails,
@@ -22,9 +31,53 @@ import { validateInput } from "./validate";
22
31
  */
23
32
  type ExecuteResult = AgentToolResult<AskUserDetails> & { isError?: boolean };
24
33
 
34
+ /** 禁用 ask_user 工具(headless / RPC 通道不可用时调用,防 LLM 反复重试)。 */
35
+ function disableAskUser(pi: ExtensionAPI): void {
36
+ pi.setActiveTools(
37
+ pi
38
+ .getAllTools()
39
+ .map((t: { name: string }) => t.name)
40
+ .filter((n: string) => n !== "ask_user"),
41
+ );
42
+ }
43
+
44
+ /** 构造 cancelled 结果(用户取消 / abort / 校验失败共用)。 */
45
+ function cancelledResult(questions: Question[], text: string, isError = false): ExecuteResult {
46
+ return {
47
+ content: [{ type: "text" as const, text }],
48
+ isError: isError || undefined,
49
+ details: { questions, answers: {}, cancelled: true } satisfies Result,
50
+ };
51
+ }
52
+
53
+ /** TUI 模式交互(自定义 Component 实时交互)。 */
54
+ async function runTuiInteraction(
55
+ questions: Question[],
56
+ signal: AbortSignal | undefined,
57
+ ctx: ExtensionContext,
58
+ ): Promise<Result | null> {
59
+ return ctx.ui.custom<Result | null>(
60
+ (tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
61
+ const comp = new AskUserComponent(
62
+ questions,
63
+ tui as { requestRender(): void },
64
+ theme as ThemeLike,
65
+ done,
66
+ );
67
+ // signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
68
+ // 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
69
+ if (signal) {
70
+ signal.addEventListener("abort", () => comp.cancel(), { once: true });
71
+ }
72
+ return comp;
73
+ },
74
+ // 不传 options → inline 渲染(spec FR-3)
75
+ );
76
+ }
77
+
25
78
  /**
26
79
  * expanded 渲染辅助:展开某问题的全部选项,用 ●/○ 标记是否被选中(spec FR-9)。
27
- * 选中判定:answer 含该 option label(answer 形如 "Postgres" / "A, B" / "X — comment")。
80
+ * 选中判定:用 parseAnswerParts 精确匹配 label(而非子串匹配),避免 "A" 是 "AB" 子串时的误判。
28
81
  * 返回 TruncatedText 数组供 box.addChild 展开。
29
82
  */
30
83
  function renderExpandedOptions(
@@ -32,9 +85,11 @@ function renderExpandedOptions(
32
85
  answer: string,
33
86
  theme: ThemeLike,
34
87
  ): TruncatedText[] {
35
- const answerTokens = new Set(answer.split(/\s*[,—]\s*|\s*,\s*/).filter(Boolean));
88
+ const labels = q.options.map((o: Option) => o.label);
89
+ const { selected } = parseAnswerParts(answer, labels);
90
+ const selectedSet = new Set(selected);
36
91
  const mark = (opt: Option): string =>
37
- answerTokens.has(opt.label) || answer.includes(opt.label)
92
+ selectedSet.has(opt.label)
38
93
  ? theme.fg("success", "●")
39
94
  : theme.fg("dim", "○");
40
95
  // 只展开真实选项(不含自动追加的 Other);Other 文本单独显示
@@ -51,6 +106,107 @@ function renderExpandedOptions(
51
106
  return out;
52
107
  }
53
108
 
109
+ /**
110
+ * 把 ask-user 内部 Question[] 映射为协议包 AskUserQuestion[](RPC 交互声明)。
111
+ *
112
+ * ask-user 的 Question.options 必填且只有 label/description(无 value 字段),
113
+ * 协议的 AskUserOption.value 缺失时前端用 label 做回传值——与 ask-user 语义一致
114
+ * (TUI 版 buildResult 也是用 label 拼 answers)。
115
+ * allowOther 固定 true:ask-user 无条件自动追加 Other(schema 不暴露此字段)。
116
+ */
117
+ function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
118
+ return questions.map((q: Question) => ({
119
+ header: q.header,
120
+ question: q.question,
121
+ context: q.context,
122
+ options: q.options.map((o: Option) => ({
123
+ label: o.label,
124
+ value: o.label, // ask-user 用 label 做回传值(与 TUI buildResult 语义一致)
125
+ description: o.description,
126
+ })),
127
+ multiSelect: q.multiSelect,
128
+ allowOther: true,
129
+ allowComment: q.allowComment ?? false,
130
+ }));
131
+ }
132
+
133
+ /**
134
+ * 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
135
+ *
136
+ * 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment
137
+ * ask-user 格式:key=question 全文, value=逗号分隔 label + Other, comment 内联(` — `)
138
+ *
139
+ * 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
140
+ * 确保 RPC 和 TUI 两条路径产出的 Result.answers 格式一致。
141
+ */
142
+ function protoAnswersToResult(
143
+ questions: Question[],
144
+ protoQuestions: AskUserQuestion[],
145
+ answers: AskUserAnswers,
146
+ ): Result["answers"] {
147
+ const out: Record<string, string> = {};
148
+ for (let i = 0; i < questions.length; i++) {
149
+ const q = questions[i]!;
150
+ const iq = protoQuestions[i]!;
151
+ const selected = getAskUserAnswer(answers, iq);
152
+ const other = getAskUserOther(answers, iq);
153
+ const comment = getAskUserComment(answers, iq);
154
+
155
+ const parts: string[] = [];
156
+ if (Array.isArray(selected)) {
157
+ // 多选按 question.options 中的定义顺序排序(S#3),
158
+ // 与 TUI 版 submit-view.ts 的 selectedIndices.sort() 语义一致,
159
+ // 确保 RPC 和 TUI 产出相同文本("A, C" 而非前端回传顺序的 "C, A")。
160
+ const orderMap = new Map(q.options.map((o: Option, idx: number) => [o.label, idx]));
161
+ const unknownOrder = q.options.length;
162
+ parts.push(...[...selected].sort((a, b) => {
163
+ const ai = orderMap.get(a) ?? unknownOrder;
164
+ const bi = orderMap.get(b) ?? unknownOrder;
165
+ return ai - bi;
166
+ }));
167
+ } else if (selected) {
168
+ parts.push(selected);
169
+ }
170
+ if (other) parts.push(other);
171
+ const formatted = formatAnswer(parts, comment);
172
+ if (formatted !== null) out[q.question] = formatted;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * RPC 模式(xyz-agent GUI)交互入口。
179
+ *
180
+ * 走 askUserInteract(select 通道 + ASK_USER_MARKER),前端 AskUserOverlay 渲染富交互 UI。
181
+ * 返回 Result(正常/取消),或抛错(select 异常 / 非 RPC 模式调用了此函数)。
182
+ *
183
+ * 注意:从 ExtensionContext 构造 GuiContext 子集传入,而非直接传 ctx——
184
+ * ExtensionContext.ui.custom 的泛型签名与 GuiContext.ui.custom 不兼容(前者复杂泛型,后者简化签名),
185
+ * 直接传会导致类型不兼容。GuiContext 只需要 mode/hasUI/ui.select,构造最小子集避免签名冲突。
186
+ */
187
+ async function runRpcInteraction(
188
+ questions: Question[],
189
+ signal: AbortSignal | undefined,
190
+ ctx: ExtensionContext,
191
+ ): Promise<Result> {
192
+ const protoQuestions = toProtoQuestions(questions);
193
+ const guiCtx = {
194
+ mode: ctx.mode,
195
+ hasUI: ctx.hasUI,
196
+ ui: { select: ctx.ui.select.bind(ctx.ui) },
197
+ };
198
+ const answers = await askUserInteract(guiCtx, protoQuestions, { signal, allowCancel: true });
199
+
200
+ if (answers === null) {
201
+ return { questions, answers: {}, cancelled: true };
202
+ }
203
+ return {
204
+ questions,
205
+ answers: protoAnswersToResult(questions, protoQuestions, answers),
206
+ cancelled: false,
207
+ };
208
+ }
209
+
54
210
  export default function (pi: ExtensionAPI): void {
55
211
  pi.registerTool({
56
212
  name: "ask_user",
@@ -81,80 +237,53 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
81
237
  ): Promise<ExecuteResult> {
82
238
  const questions = params.questions;
83
239
 
84
- // 1. 参数校验(spec FR-2)→ isError
240
+ // 1. 参数校验(spec FR-2)
85
241
  const validationError = validateInput(questions);
86
242
  if (validationError) {
87
- return {
88
- content: [{ type: "text" as const, text: `Error: ${validationError}` }],
89
- isError: true,
90
- details: { questions, answers: {}, cancelled: true } satisfies Result,
91
- };
243
+ return cancelledResult(questions, `Error: ${validationError}`, true);
92
244
  }
93
245
 
94
- // 2. Headless 检查(spec FR-8)→ isError + 禁用工具
95
- if (!ctx.hasUI) {
96
- pi.setActiveTools(
97
- pi
98
- .getAllTools()
99
- .map((t: { name: string }) => t.name)
100
- .filter((n: string) => n !== "ask_user"),
246
+ // 2. Headless 检查(spec FR-8):mode 非 tui 非 rpc = 无交互通道
247
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
248
+ disableAskUser(pi);
249
+ return cancelledResult(
250
+ questions,
251
+ "Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
252
+ true,
101
253
  );
102
- return {
103
- content: [
104
- {
105
- type: "text" as const,
106
- text: "Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
107
- },
108
- ],
109
- isError: true,
110
- details: { questions, answers: {}, cancelled: true } satisfies Result,
111
- };
112
254
  }
113
255
 
114
256
  // 3. Signal abort 入口检查(spec FR-10)。
115
257
  // abort 是 agent 被外部终止(goal 取消 / context compact / session 切换),
116
258
  // 不是用户意图——文案必须区别于 step 5 的用户取消,避免 LLM 俊等用户。
117
259
  if (signal?.aborted) {
118
- return {
119
- content: [
120
- {
121
- type: "text" as const,
122
- text: "Agent aborted (goal cancelled, context compacted, or session switched). Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required.",
123
- },
124
- ],
125
- details: { questions, answers: {}, cancelled: true } satisfies Result,
126
- };
260
+ return cancelledResult(
261
+ questions,
262
+ "Agent aborted (goal cancelled, context compacted, or session switched). Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required.",
263
+ );
127
264
  }
128
265
 
129
- // 4. 顶层 try/catch(spec FR-13)
266
+ // 4. 交互执行:TUI 走 ctx.ui.custom,RPC(xyz-agent GUI)走 askUserInteract。
267
+ // 注意:hasUI 在 TUI 和 RPC 模式都为 true(dialog-capable),不能用于区分——
268
+ // 用 ctx.mode === 'rpc' 判定 GUI 渲染通道。
269
+ const useRpc = ctx.mode === "rpc";
130
270
  let result: Result | null;
131
271
  try {
132
- result = await ctx.ui.custom<Result | null>(
133
- (tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
134
- const comp = new AskUserComponent(
135
- questions,
136
- tui as { requestRender(): void },
137
- theme as ThemeLike,
138
- done,
139
- );
140
- // signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
141
- // 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
142
- if (signal) {
143
- signal.addEventListener("abort", () => comp.cancel(), { once: true });
144
- }
145
- return comp;
146
- },
147
- // 不传 options → inline 渲染(spec FR-3)
148
- );
272
+ result = useRpc
273
+ ? await runRpcInteraction(questions, signal, ctx)
274
+ : await runTuiInteraction(questions, signal, ctx);
149
275
  } catch (err) {
276
+ // RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
277
+ // TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
150
278
  const message = err instanceof Error ? err.message : String(err);
279
+ if (useRpc) disableAskUser(pi);
151
280
  return {
152
- content: [
153
- {
154
- type: "text" as const,
155
- text: `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
156
- },
157
- ],
281
+ content: [{
282
+ type: "text" as const,
283
+ text: useRpc
284
+ ? `ask_user failed: ${message}. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.`
285
+ : `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
286
+ }],
158
287
  isError: true,
159
288
  details: { error: message } satisfies ErrorDetails,
160
289
  };
@@ -162,15 +291,10 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
162
291
 
163
292
  // 5. 取消(null / cancelled)
164
293
  if (result === null || result.cancelled) {
165
- return {
166
- content: [
167
- {
168
- type: "text" as const,
169
- text: "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
170
- },
171
- ],
172
- details: { questions, answers: {}, cancelled: true } satisfies Result,
173
- };
294
+ return cancelledResult(
295
+ questions,
296
+ "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
297
+ );
174
298
  }
175
299
 
176
300
  // 6. 正常返回
@@ -26,6 +26,17 @@ export interface DisplayOption {
26
26
  isOther?: boolean;
27
27
  }
28
28
 
29
+ /** 渲染上下文——收拢 q/state/theme/width 四个总是成对出现的参数。
30
+ * draftText 从 state.draftText 取(不再重复传递)。 */
31
+ export interface RenderContext {
32
+ question: Question;
33
+ state: QuestionState;
34
+ theme: ThemeLike;
35
+ width: number;
36
+ /** 单问题模式(无 Tab 提示)。 */
37
+ isSingle: boolean;
38
+ }
39
+
29
40
  /** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
30
41
  const MAX_EDITOR_LINES = 5;
31
42
 
@@ -112,14 +123,10 @@ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit
112
123
  * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
113
124
  * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
114
125
  function buildOptionLines(
115
- q: Question,
116
- state: QuestionState,
117
- theme: ThemeLike,
118
- width: number,
126
+ ctx: RenderContext,
119
127
  hideDescriptions: boolean,
120
- draftText: string = "",
121
128
  ): string[] {
122
- const t = theme;
129
+ const { question: q, state, theme: t, width } = ctx;
123
130
  const opts = allOptions(q);
124
131
  const lines: string[] = [];
125
132
  const add = (s: string): void => {
@@ -145,7 +152,7 @@ function buildOptionLines(
145
152
  const lead = `${prefix} ${marker} `;
146
153
  const avail = Math.max(1, width - visibleWidth(lead));
147
154
  // 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
148
- const cursorText = renderCursorText(draftText, state.cursorIndex);
155
+ const cursorText = renderCursorText(state.draftText, state.cursorIndex);
149
156
  const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
150
157
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
151
158
  } else {
@@ -195,13 +202,10 @@ function buildOptionLines(
195
202
 
196
203
  /** 构建分屏右侧 Markdown 详情预览。 */
197
204
  function buildPreviewLines(
198
- q: Question,
199
- state: QuestionState,
200
- theme: ThemeLike,
201
- width: number,
205
+ ctx: RenderContext,
202
206
  maxLines: number,
203
207
  ): string[] {
204
- const t = theme;
208
+ const { question: q, state, theme: t, width } = ctx;
205
209
  const opts = allOptions(q);
206
210
  const opt = opts[state.cursorIndex];
207
211
  if (!opt) return [t.fg("dim", "—")];
@@ -226,16 +230,12 @@ function buildPreviewLines(
226
230
  * comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
227
231
  */
228
232
  function buildEditorBlock(
229
- theme: ThemeLike,
230
- width: number,
231
- mode: "freeform" | "comment",
232
- draftText: string,
233
- cursorIndex?: number,
233
+ ctx: RenderContext,
234
234
  ): string[] {
235
- if (mode === "freeform") {
235
+ const { state, theme: t, width } = ctx;
236
+ if (state.mode === "freeform") {
236
237
  return [""];
237
238
  }
238
- const t = theme;
239
239
  const lines: string[] = [];
240
240
  const add = (s: string): void => {
241
241
  lines.push(truncateToWidth(s, width));
@@ -244,8 +244,7 @@ function buildEditorBlock(
244
244
  const prompt = t.fg("muted", " Your comment (optional):");
245
245
  add(prompt);
246
246
  // 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
247
- const pos = cursorIndex ?? draftText.length;
248
- const cursorText = renderCursorText(draftText, pos);
247
+ const cursorText = renderCursorText(state.draftText, state.cursorIndex);
249
248
  add(` ${t.fg("text", cursorText)}`);
250
249
  add("");
251
250
  add(t.fg("dim", EDITOR_HINT));
@@ -254,20 +253,18 @@ function buildEditorBlock(
254
253
 
255
254
  /** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
256
255
  function buildSplitPane(
257
- q: Question,
258
- state: QuestionState,
259
- theme: ThemeLike,
256
+ ctx: RenderContext,
260
257
  split: { left: number; right: number },
261
- width: number,
262
- draftText: string = "",
263
258
  ): string[] {
264
- const t = theme;
259
+ const { theme: t, width } = ctx;
265
260
  const lines: string[] = [];
266
261
  const add = (s: string): void => {
267
262
  lines.push(truncateToWidth(s, width));
268
263
  };
269
- const leftLines = buildOptionLines(q, state, theme, split.left, true, draftText);
270
- const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
264
+ const leftCtx: RenderContext = { ...ctx, width: split.left };
265
+ const rightCtx: RenderContext = { ...ctx, width: split.right };
266
+ const leftLines = buildOptionLines(leftCtx, true);
267
+ const rightLines = buildPreviewLines(rightCtx, Math.max(leftLines.length, 8));
271
268
  const rowCount = Math.max(leftLines.length, rightLines.length);
272
269
  const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
273
270
  for (let i = 0; i < rowCount; i++) {
@@ -280,18 +277,9 @@ function buildSplitPane(
280
277
 
281
278
  /**
282
279
  * 渲染单个问题视图(spec FR-4)。
283
- * isSingle: 单问题模式(无 Tab 提示)。
284
- * draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
285
280
  */
286
- export function renderQuestionView(
287
- q: Question,
288
- state: QuestionState,
289
- theme: ThemeLike,
290
- width: number,
291
- isSingle: boolean,
292
- draftText: string,
293
- ): string[] {
294
- const t = theme;
281
+ export function renderQuestionView(ctx: RenderContext): string[] {
282
+ const { question: q, state, theme: t, width, isSingle } = ctx;
295
283
  const lines: string[] = [];
296
284
  const add = (s: string): void => {
297
285
  lines.push(truncateToWidth(s, width));
@@ -323,10 +311,8 @@ export function renderQuestionView(
323
311
  // 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
324
312
  if (state.mode === "freeform" || state.mode === "comment") {
325
313
  add("");
326
- const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
327
- for (const line of optionLines) add(line);
328
- const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
329
- lines.push(...editorBlock);
314
+ for (const line of buildOptionLines(ctx, false)) add(line);
315
+ lines.push(...buildEditorBlock(ctx));
330
316
  if (state.mode === "freeform") {
331
317
  // freeform 模式 help 行:光标锁在 Other 上,正在输入
332
318
  add(t.fg("dim", EDITOR_HINT));
@@ -336,11 +322,10 @@ export function renderQuestionView(
336
322
 
337
323
  if (!split) {
338
324
  // 单列模式
339
- const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
340
- for (const line of optionLines) add(line);
325
+ for (const line of buildOptionLines(ctx, false)) add(line);
341
326
  } else {
342
327
  // 分屏模式
343
- lines.push(...buildSplitPane(q, state, theme, split, width, draftText));
328
+ lines.push(...buildSplitPane(ctx, split));
344
329
  }
345
330
 
346
331
  add("");
@@ -1,8 +1,8 @@
1
1
  // src/submit-view.ts
2
2
  import { truncateToWidth } from "@mariozechner/pi-tui";
3
3
 
4
+ import { formatAnswer } from "./answer-format";
4
5
  import {
5
- ANSWER_COMMENT_SEPARATOR,
6
6
  HEADER_MAX_CHARS,
7
7
  type Question,
8
8
  type QuestionState,
@@ -44,9 +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
- if (parts.length === 0) return null;
48
- const base = parts.join(", ");
49
- return s.commentValue ? `${base}${ANSWER_COMMENT_SEPARATOR}${s.commentValue}` : base;
47
+ return formatAnswer(parts, s.commentValue);
50
48
  }
51
49
 
52
50
  /**
package/src/validate.ts CHANGED
@@ -55,6 +55,17 @@ export function validateInput(questions: Question[]): string | null {
55
55
  return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars.`;
56
56
  }
57
57
  }
58
+
59
+ // S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
60
+ // 后一个 question 的 __other/__comment 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
61
+ const seenHeaders = new Set<string>();
62
+ for (const q of questions) {
63
+ const h = q.header!.trim();
64
+ if (seenHeaders.has(h)) {
65
+ 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.`;
66
+ }
67
+ seenHeaders.add(h);
68
+ }
58
69
  }
59
70
 
60
71
  // 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,