@zhushanwen/pi-ask-user 6.0.1 → 7.0.2

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/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 / comment mode → editor text input
205
- if (state.mode === "freeform" || state.mode === "comment") {
204
+ // freeform mode → editor text input
205
+ if (state.mode === "freeform") {
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, q);
267
+ this.afterConfirm(state);
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, q);
274
+ this.afterConfirm(state);
275
275
  return;
276
276
  }
277
277
  }
@@ -364,16 +364,8 @@ export class AskUserComponent implements Component {
364
364
  // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
365
365
  }
366
366
 
367
- /** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
367
+ /** Esc: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
- }
377
369
  // freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
378
370
  // so discarded drafts don't pollute the answer or trigger auto-confirm.
379
371
  state.freeDraft = state.draftText || null;
@@ -383,34 +375,26 @@ export class AskUserComponent implements Component {
383
375
  this.rerender();
384
376
  }
385
377
 
386
- /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
378
+ /** Enter:freeform 有文本→提交,空文本→回退。 */
387
379
  private handleEditorEnter(state: QuestionState, q: Question): void {
388
380
  const text = state.draftText.trim();
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();
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;
406
395
  }
407
- return;
396
+ this.rerender();
408
397
  }
409
- state.commentValue = text || null;
410
- state.mode = "options";
411
- state.draftText = "";
412
- state.cursorIndex = state.savedOptionsCursorIndex;
413
- this.advance();
414
398
  }
415
399
 
416
400
  private toggleIndex(state: QuestionState, index: number): void {
@@ -450,17 +434,9 @@ export class AskUserComponent implements Component {
450
434
  this.rerender();
451
435
  }
452
436
 
453
- /** 选中确认后的处理:若 allowComment(4.0.1 restore 字段),进入评论模式(可重入编辑/清除已有评论);否则前进。 */
454
- private afterConfirm(state: QuestionState, q: Question): void {
437
+ /** 选中确认后的处理:标记 confirmed 并前进。 */
438
+ private afterConfirm(state: QuestionState): void {
455
439
  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
- }
464
440
  this.advance();
465
441
  }
466
442
 
package/src/index.ts CHANGED
@@ -7,15 +7,15 @@ import {
7
7
  askUserInteract,
8
8
  type AskUserQuestion,
9
9
  getAskUserAnswer,
10
- getAskUserComment,
11
10
  getAskUserOther,
12
11
  } from "@xyz-agent/extension-protocol";
13
12
 
14
- import { formatAnswer, parseAnswerParts } from "./answer-format";
15
13
  import { createAskUserChannelHandler } from "./channel-handler";
16
14
  import { registerAskUserChannelHandler } from "./channel-registry-register";
17
15
  import { AskUserComponent } from "./component";
16
+ import { answerValueText } from "./submit-view";
18
17
  import {
18
+ type AnswerValue,
19
19
  type AskUserDetails,
20
20
  type ErrorDetails,
21
21
  HEADER_MAX_CHARS,
@@ -79,17 +79,15 @@ async function runTuiInteraction(
79
79
 
80
80
  /**
81
81
  * expanded 渲染辅助:展开某问题的全部选项,用 ●/○ 标记是否被选中(spec FR-9)。
82
- * 选中判定:用 parseAnswerParts 精确匹配 label(而非子串匹配),避免 "A" 是 "AB" 子串时的误判。
82
+ * 选中判定:直接读结构化 AnswerValue.selected(Set 精确匹配,无文本解析)。
83
83
  * 返回 TruncatedText 数组供 box.addChild 展开。
84
84
  */
85
85
  function renderExpandedOptions(
86
86
  q: Question,
87
- answer: string,
87
+ answer: AnswerValue | undefined,
88
88
  theme: ThemeLike,
89
89
  ): TruncatedText[] {
90
- const labels = q.options.map((o: Option) => o.label);
91
- const { selected } = parseAnswerParts(answer, labels);
92
- const selectedSet = new Set(selected);
90
+ const selectedSet = new Set(answer?.selected ?? []);
93
91
  const mark = (opt: Option): string =>
94
92
  selectedSet.has(opt.label)
95
93
  ? theme.fg("success", "●")
@@ -111,9 +109,8 @@ function renderExpandedOptions(
111
109
  /**
112
110
  * 把 ask-user 内部 Question[] 映射为协议包 AskUserQuestion[](RPC 交互声明)。
113
111
  *
114
- * ask-user 的 Question.options 必填且只有 label/description(无 value 字段),
115
- * 协议的 AskUserOption.value 缺失时前端用 label 做回传值——与 ask-user 语义一致
116
- * (TUI 版 buildResult 也是用 label 拼 answers)。
112
+ * ask-user 的 Question.options 只有 label/description(协议 AskUserOption 也无 value
113
+ * 字段——回传值统一用 label,与 ask-user 语义一致)。
117
114
  * allowOther 固定 true:ask-user 无条件自动追加 Other(schema 不暴露此字段)。
118
115
  */
119
116
  function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
@@ -123,55 +120,55 @@ function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
123
120
  context: q.context,
124
121
  options: q.options.map((o: Option) => ({
125
122
  label: o.label,
126
- value: o.label, // ask-user 用 label 做回传值(与 TUI buildResult 语义一致)
127
123
  description: o.description,
128
124
  })),
129
125
  multiSelect: q.multiSelect,
130
126
  allowOther: true,
131
- allowComment: q.allowComment ?? false,
132
127
  }));
133
128
  }
134
129
 
135
130
  /**
136
- * 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
131
+ * 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers(结构化 AnswerValue)。
137
132
  *
138
- * 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment(5.0.0-dev.1 restore)
139
- * ask-user 格式:key=question 全文, value=逗号分隔 label + Other, comment 内联(` — `)
133
+ * 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other
134
+ * ask-user 格式:key=question 全文, value=AnswerValue(selected=label 数组、other=自由文本)
140
135
  *
141
- * 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
142
- * 确保 RPC 和 TUI 两条路径产出的 Result.answers 格式一致。
136
+ * 解码走协议包 helper(getAskUserAnswer/getAskUserOther,proto 格式的唯一解码 SSOT);
137
+ * selected 空 && other 无 → 该问题未答,跳过不写入。
143
138
  */
144
139
  function protoAnswersToResult(
145
140
  questions: Question[],
146
141
  protoQuestions: AskUserQuestion[],
147
142
  answers: AskUserAnswers,
148
- ): Result["answers"] {
149
- const out: Record<string, string> = {};
143
+ ): Record<string, AnswerValue> {
144
+ const out: Record<string, AnswerValue> = {};
150
145
  for (let i = 0; i < questions.length; i++) {
151
146
  const q = questions[i]!;
152
147
  const iq = protoQuestions[i]!;
153
148
  const selected = getAskUserAnswer(answers, iq);
154
- const other = getAskUserOther(answers, iq);
155
- const comment = getAskUserComment(answers, iq);
149
+ const other = getAskUserOther(answers, iq) ?? null;
156
150
 
157
- const parts: string[] = [];
151
+ let selectedArr: string[];
158
152
  if (Array.isArray(selected)) {
159
153
  // 多选按 question.options 中的定义顺序排序(S#3),
160
154
  // 与 TUI 版 submit-view.ts 的 selectedIndices.sort() 语义一致,
161
- // 确保 RPC 和 TUI 产出相同文本("A, C" 而非前端回传顺序的 "C, A")。
155
+ // 确保 RPC 和 TUI 产出相同顺序("A, C" 而非前端回传顺序的 "C, A")。
162
156
  const orderMap = new Map(q.options.map((o: Option, idx: number) => [o.label, idx]));
163
157
  const unknownOrder = q.options.length;
164
- parts.push(...[...selected].sort((a, b) => {
158
+ selectedArr = [...selected].sort((a, b) => {
165
159
  const ai = orderMap.get(a) ?? unknownOrder;
166
160
  const bi = orderMap.get(b) ?? unknownOrder;
167
161
  return ai - bi;
168
- }));
162
+ });
169
163
  } else if (selected) {
170
- parts.push(selected);
164
+ selectedArr = [selected];
165
+ } else {
166
+ selectedArr = [];
171
167
  }
172
- if (other) parts.push(other);
173
- const formatted = formatAnswer(parts, comment);
174
- if (formatted !== null) out[q.question] = formatted;
168
+
169
+ // selected 空 && other 无 → 该问题未答,跳过不写入(与 TUI buildResult 一致)
170
+ if (selectedArr.length === 0 && other === null) continue;
171
+ out[q.question] = { selected: selectedArr, other };
175
172
  }
176
173
  return out;
177
174
  }
@@ -329,9 +326,10 @@ Don't:
329
326
  }
330
327
 
331
328
  // 6. 正常返回
332
- const summary = result.questions.map(
333
- (q: Question) => `"${q.question}" = "${result!.answers[q.question] ?? "(no answer)"}"`,
334
- );
329
+ const summary = result.questions.map((q: Question) => {
330
+ const av = result!.answers[q.question];
331
+ return `"${q.question}" = "${av ? answerValueText(av) : "(no answer)"}"`;
332
+ });
335
333
  return {
336
334
  content: [{ type: "text" as const, text: summary.join("\n") }],
337
335
  details: result satisfies Result,
@@ -366,17 +364,18 @@ Don't:
366
364
  const box = new Box(0, 0);
367
365
  for (const q of d.questions) {
368
366
  const header = q.header ?? truncateToWidth(q.question, HEADER_MAX_CHARS);
369
- const answer = d.answers[q.question] ?? "(no answer)";
367
+ const answer = d.answers[q.question];
368
+ const answerText = answer ? answerValueText(answer) : "(no answer)";
370
369
  box.addChild(
371
370
  new TruncatedText(
372
371
  theme.fg("success", "✓ ") +
373
372
  theme.fg("accent", `${header}: `) +
374
- theme.fg("text", answer),
373
+ theme.fg("text", answerText),
375
374
  0,
376
375
  0,
377
376
  ),
378
377
  );
379
- // options.expanded:展开显示该问题全部选项 + ●/○ 选中标记 + 评论(spec FR-9)
378
+ // options.expanded:展开显示该问题全部选项 + ●/○ 选中标记(spec FR-9)
380
379
  if (options?.expanded) {
381
380
  for (const child of renderExpandedOptions(q, answer, theme)) box.addChild(child);
382
381
  }
@@ -119,9 +119,10 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
119
119
  * 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
120
120
  const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
121
121
 
122
- /** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
123
- * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
124
- * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
122
+ /**
123
+ * 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
124
+ * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选)。
125
+ */
125
126
  function buildOptionLines(
126
127
  ctx: RenderContext,
127
128
  hideDescriptions: boolean,
@@ -136,7 +137,7 @@ function buildOptionLines(
136
137
  for (let i = 0; i < opts.length; i++) {
137
138
  const opt = opts[i]!;
138
139
  // 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
139
- const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
140
+ const activeOptionCursor = state.mode === "freeform" ? state.savedOptionsCursorIndex : state.cursorIndex;
140
141
  const isSelected = i === activeOptionCursor;
141
142
  const isOther = opt.isOther === true;
142
143
  const prefix = isSelected ? t.fg("accent", ">") : " ";
@@ -224,33 +225,6 @@ function buildPreviewLines(
224
225
  return lines;
225
226
  }
226
227
 
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
-
254
228
  /** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
255
229
  function buildSplitPane(
256
230
  ctx: RenderContext,
@@ -277,8 +251,7 @@ function buildSplitPane(
277
251
 
278
252
  /**
279
253
  * 渲染单个问题视图(spec FR-4)。
280
- */
281
- export function renderQuestionView(ctx: RenderContext): string[] {
254
+ */export function renderQuestionView(ctx: RenderContext): string[] {
282
255
  const { question: q, state, theme: t, width, isSingle } = ctx;
283
256
  const lines: string[] = [];
284
257
  const add = (s: string): void => {
@@ -302,21 +275,16 @@ export function renderQuestionView(ctx: RenderContext): string[] {
302
275
 
303
276
  // 选项模式下:question/context 与 options 之间加分割线(三段式)
304
277
  // 编辑器模式不加(编辑器块自带视觉边界)
305
- if (state.mode !== "freeform" && state.mode !== "comment") {
278
+ if (state.mode !== "freeform") {
306
279
  divider();
307
280
  }
308
281
 
309
- // 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines 原地渲染)。
310
- // 编辑器模式一律用全 width 单列渲染——分屏左列仅约 42% 宽,Other 自由输入会被压窄换行,
311
- // 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
312
- if (state.mode === "freeform" || state.mode === "comment") {
282
+ // freeform 模式 help 行:光标锁在 Other 上,正在输入;编辑器行前留一个空行与选项列表分开。
283
+ if (state.mode === "freeform") {
313
284
  add("");
314
285
  for (const line of buildOptionLines(ctx, false)) add(line);
315
- lines.push(...buildEditorBlock(ctx));
316
- if (state.mode === "freeform") {
317
- // freeform 模式 help 行:光标锁在 Other 上,正在输入
318
- add(t.fg("dim", EDITOR_HINT));
319
- }
286
+ add("");
287
+ add(t.fg("dim", EDITOR_HINT));
320
288
  return lines;
321
289
  }
322
290
 
@@ -1,8 +1,8 @@
1
1
  // src/submit-view.ts
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
- import { formatAnswer } from "./answer-format";
5
4
  import {
5
+ type AnswerValue,
6
6
  HEADER_MAX_CHARS,
7
7
  type Question,
8
8
  type QuestionState,
@@ -27,24 +27,35 @@ export function renderButtonBar(theme: ThemeLike, allDone: boolean, focus: "subm
27
27
  }
28
28
 
29
29
  /**
30
- * 获取单问题的答案文本(供 Submit tab 显示)。
30
+ * 把结构化 AnswerValue 格式化为展示文本(TUI Submit tab / index.ts renderResult 共用)。
31
+ * 规则:selected join(", ");other 非空时追加 ", ${other}"。
32
+ */
33
+ export function answerValueText(v: AnswerValue): string {
34
+ const parts = [...v.selected];
35
+ if (v.other !== null && v.other !== "") parts.push(v.other);
36
+ return parts.join(", ");
37
+ }
38
+
39
+ /**
40
+ * 获取单问题的结构化答案(供 Submit tab 显示)。
31
41
  * 返回 null 表示未答。
32
42
  */
33
- export function getAnswerText(q: Question, s: QuestionState): string | null {
43
+ export function getAnswerText(q: Question, s: QuestionState): AnswerValue | null {
34
44
  if (!s.confirmed) return null;
35
- const parts: string[] = [];
45
+ const selected: string[] = [];
36
46
  if (q.multiSelect) {
37
47
  const labels = [...s.selectedIndices]
38
48
  .sort((a, b) => a - b)
39
49
  .map((idx) => q.options[idx]?.label)
40
50
  .filter((l): l is string => !!l);
41
- parts.push(...labels);
51
+ selected.push(...labels);
42
52
  } else if (s.selectedIndex !== null) {
43
53
  const label = q.options[s.selectedIndex]?.label;
44
- if (label) parts.push(label);
54
+ if (label) selected.push(label);
45
55
  }
46
- if (s.freeTextValue !== null) parts.push(s.freeTextValue);
47
- return formatAnswer(parts, s.commentValue);
56
+ // confirmed 但无任何答案内容(selected 空 && other 空)→ null(未答,buildResult 不写入)
57
+ if (selected.length === 0 && (s.freeTextValue === null || s.freeTextValue === "")) return null;
58
+ return { selected, other: s.freeTextValue };
48
59
  }
49
60
 
50
61
  /**
@@ -79,7 +90,7 @@ export function renderSubmitView(
79
90
  const answer = getAnswerText(q, states[i]!);
80
91
  const headerLabel = truncateToWidth(q.header ?? "", HEADER_MAX_CHARS);
81
92
  if (answer !== null) {
82
- add(` ${t.fg("muted", `${headerLabel}: `)}${t.fg("text", answer)}`);
93
+ add(` ${t.fg("muted", `${headerLabel}: `)}${t.fg("text", answerValueText(answer))}`);
83
94
  } else {
84
95
  add(` ${t.fg("dim", `${headerLabel}: `)}${t.fg("warning", "—")}`);
85
96
  }
@@ -111,12 +122,12 @@ export function renderSubmitView(
111
122
  * 从 states 构建 Result(供组件 buildResult 调用)。
112
123
  */
113
124
  export function buildResult(questions: Question[], states: QuestionState[]): Result {
114
- const answers: Record<string, string> = {};
125
+ const answers: Record<string, AnswerValue> = {};
115
126
  for (let i = 0; i < questions.length; i++) {
116
127
  const q = questions[i]!;
117
128
  const s = states[i]!;
118
- const text = getAnswerText(q, s);
119
- if (text !== null) answers[q.question] = text;
129
+ const answer = getAnswerText(q, s);
130
+ if (answer !== null) answers[q.question] = answer;
120
131
  }
121
132
  return { questions, answers, cancelled: false };
122
133
  }
package/src/types.ts CHANGED
@@ -10,7 +10,6 @@ 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)
14
13
 
15
14
  // ── Input schema(LLM 调用参数) ─────────────────────
16
15
  // description 用英文:这些字符串会进 LLM 的 tool schema,英文更利于模型理解。
@@ -47,9 +46,6 @@ export const QuestionSchema = Type.Object({
47
46
  multiSelect: Type.Optional(
48
47
  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." }),
49
48
  ),
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
- ),
53
49
  });
54
50
 
55
51
  /**
@@ -94,10 +90,20 @@ export type Question = Static<typeof QuestionSchema>;
94
90
  /** LLM 入参 question 形状:options 可能含 string 误用,validateInput 负责友好拦截。 */
95
91
  export type InputQuestion = Static<typeof InputSchema>["questions"][number];
96
92
 
93
+ // ── 结构化答案模型(details.answers 的值形态) ─────────
94
+ // selected = 选中的 option label 数组(单选长度 0/1,多选任意;不含 Other 文本);
95
+ // other = Other 自由文本(null=未输入)。answers 的 key = question 全文。
96
+ export const AnswerValueSchema = Type.Object({
97
+ selected: Type.Array(Type.String()),
98
+ other: Type.Union([Type.String(), Type.Null()]),
99
+ });
100
+
101
+ export type AnswerValue = Static<typeof AnswerValueSchema>;
102
+
97
103
  // ── Result schema(details,renderResult 数据源) ─────
98
104
  export const ResultSchema = Type.Object({
99
105
  questions: Type.Array(QuestionSchema),
100
- answers: Type.Record(Type.String(), Type.String()),
106
+ answers: Type.Record(Type.String(), AnswerValueSchema),
101
107
  cancelled: Type.Boolean(),
102
108
  });
103
109
 
@@ -123,7 +129,7 @@ export interface ThemeLike {
123
129
  }
124
130
 
125
131
  /** 单问题的交互模式 */
126
- export type QuestionMode = "options" | "freeform" | "comment";
132
+ export type QuestionMode = "options" | "freeform";
127
133
 
128
134
  /** 单问题的交互状态(每问题一个实例) */
129
135
  export interface QuestionState {
@@ -140,8 +146,6 @@ export interface QuestionState {
140
146
  /** freeform Esc 保存的未提交草稿;null=无草稿。
141
147
  * 与 freeTextValue(已提交答案)分离,避免放弃的草稿污染答案、触发 auto-confirm。 */
142
148
  freeDraft: string | null;
143
- /** 可选评论;null=未输入 */
144
- commentValue: string | null;
145
149
  /** 当前交互模式 */
146
150
  mode: QuestionMode;
147
151
  /** 编辑器草稿文本(每问题独立持有,进编辑器时预填、退出时清空) */
@@ -159,7 +163,6 @@ export function createQuestionState(): QuestionState {
159
163
  confirmed: false,
160
164
  freeTextValue: null,
161
165
  freeDraft: null,
162
- commentValue: null,
163
166
  mode: "options",
164
167
  draftText: "",
165
168
  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/__comment 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
75
+ // 后一个 question 的 __other 覆盖前一个(协议 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/comment 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 overwrites another's). Rephrase one header to differ.`;
81
81
  }
82
82
  seenHeaders.add(h);
83
83
  }