@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/ARCHITECTURE.md +68 -49
- package/package.json +5 -4
- package/src/__tests__/answer-codec.test.ts +102 -0
- package/src/__tests__/channel-handler.test.ts +16 -99
- package/src/__tests__/component-keymap.test.ts +2 -30
- package/src/__tests__/component.test.ts +59 -170
- package/src/__tests__/e2e.test.ts +6 -53
- package/src/__tests__/fixtures.ts +0 -15
- package/src/__tests__/index.test.ts +24 -92
- package/src/__tests__/question-view.test.ts +1 -30
- package/src/__tests__/submit-view.test.ts +13 -19
- package/src/__tests__/types.test.ts +0 -1
- package/src/__tests__/validate.test.ts +4 -10
- package/src/__tests__/w2-draft-hint.test.ts +1 -21
- package/src/__tests__/w3-regression.test.ts +3 -31
- package/src/answer-codec.ts +33 -0
- package/src/channel-handler.ts +18 -49
- package/src/component.ts +23 -47
- package/src/index.ts +34 -35
- package/src/question-view.ts +11 -43
- package/src/submit-view.ts +23 -12
- package/src/types.ts +12 -9
- package/src/validate.ts +2 -2
- package/src/__tests__/answer-format.test.ts +0 -275
- package/src/answer-format.ts +0 -127
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
|
|
205
|
-
if (state.mode === "freeform"
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
378
|
+
/** Enter:freeform 有文本→提交,空文本→回退。 */
|
|
387
379
|
private handleEditorEnter(state: QuestionState, q: Question): void {
|
|
388
380
|
const text = state.draftText.trim();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
454
|
-
private afterConfirm(state: QuestionState
|
|
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
|
-
*
|
|
82
|
+
* 选中判定:直接读结构化 AnswerValue.selected(Set 精确匹配,无文本解析)。
|
|
83
83
|
* 返回 TruncatedText 数组供 box.addChild 展开。
|
|
84
84
|
*/
|
|
85
85
|
function renderExpandedOptions(
|
|
86
86
|
q: Question,
|
|
87
|
-
answer:
|
|
87
|
+
answer: AnswerValue | undefined,
|
|
88
88
|
theme: ThemeLike,
|
|
89
89
|
): TruncatedText[] {
|
|
90
|
-
const
|
|
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
|
|
115
|
-
*
|
|
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
|
|
139
|
-
* ask-user 格式:key=question 全文, value
|
|
133
|
+
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other
|
|
134
|
+
* ask-user 格式:key=question 全文, value=AnswerValue(selected=label 数组、other=自由文本)
|
|
140
135
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
):
|
|
149
|
-
const out: Record<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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
selectedArr = [selected];
|
|
165
|
+
} else {
|
|
166
|
+
selectedArr = [];
|
|
171
167
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
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
|
-
|
|
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]
|
|
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",
|
|
373
|
+
theme.fg("text", answerText),
|
|
375
374
|
0,
|
|
376
375
|
0,
|
|
377
376
|
),
|
|
378
377
|
);
|
|
379
|
-
// options.expanded:展开显示该问题全部选项 + ●/○
|
|
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
|
}
|
package/src/question-view.ts
CHANGED
|
@@ -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
|
-
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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 =
|
|
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"
|
|
278
|
+
if (state.mode !== "freeform") {
|
|
306
279
|
divider();
|
|
307
280
|
}
|
|
308
281
|
|
|
309
|
-
//
|
|
310
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
package/src/submit-view.ts
CHANGED
|
@@ -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
|
-
*
|
|
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):
|
|
43
|
+
export function getAnswerText(q: Question, s: QuestionState): AnswerValue | null {
|
|
34
44
|
if (!s.confirmed) return null;
|
|
35
|
-
const
|
|
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
|
-
|
|
51
|
+
selected.push(...labels);
|
|
42
52
|
} else if (s.selectedIndex !== null) {
|
|
43
53
|
const label = q.options[s.selectedIndex]?.label;
|
|
44
|
-
if (label)
|
|
54
|
+
if (label) selected.push(label);
|
|
45
55
|
}
|
|
46
|
-
|
|
47
|
-
|
|
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,
|
|
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
|
|
119
|
-
if (
|
|
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(),
|
|
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"
|
|
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
|
|
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
|
|
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
|
}
|