@zhushanwen/pi-ask-user 3.0.0 → 4.0.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/package.json +4 -4
- package/src/__tests__/answer-format.test.ts +1 -36
- package/src/__tests__/channel-handler.test.ts +1 -35
- package/src/__tests__/component-keymap.test.ts +0 -40
- package/src/__tests__/component.test.ts +26 -133
- package/src/__tests__/e2e.test.ts +2 -49
- package/src/__tests__/fixtures.ts +1 -3
- package/src/__tests__/index.test.ts +1 -63
- package/src/__tests__/question-view.test.ts +1 -30
- package/src/__tests__/submit-view.test.ts +0 -20
- package/src/__tests__/types.test.ts +0 -1
- package/src/__tests__/validate.test.ts +1 -1
- package/src/__tests__/w2-draft-hint.test.ts +0 -20
- package/src/__tests__/w3-regression.test.ts +1 -29
- package/src/answer-format.ts +9 -21
- package/src/channel-handler.ts +7 -17
- package/src/component.ts +23 -47
- package/src/index.ts +3 -6
- package/src/question-view.ts +8 -36
- package/src/submit-view.ts +1 -1
- package/src/types.ts +1 -8
- package/src/validate.ts +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview
|
|
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.",
|
|
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.
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.3.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": "
|
|
41
|
+
"@zhushanwen/pi-subagent-workflow": "4.0.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@earendil-works/pi-tui": {
|
|
@@ -4,23 +4,16 @@
|
|
|
4
4
|
// 覆盖审查 S5 发现的覆盖盲区:
|
|
5
5
|
// - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
|
|
6
6
|
// - formatAnswer 空 parts → null
|
|
7
|
-
// - comment 分隔符边界
|
|
8
7
|
|
|
9
8
|
import { describe, expect, it } from "vitest";
|
|
10
9
|
|
|
11
10
|
import { formatAnswer, parseAnswerParts } from "../answer-format.js";
|
|
12
|
-
import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
|
|
13
11
|
|
|
14
12
|
describe("formatAnswer", () => {
|
|
15
13
|
it("returns null for empty parts (unanswered)", () => {
|
|
16
14
|
expect(formatAnswer([])).toBeNull();
|
|
17
15
|
});
|
|
18
16
|
|
|
19
|
-
it("returns null for empty parts even with comment", () => {
|
|
20
|
-
// parts 空 = 没有选中选项,即使有 comment 也不应产出有效答案行
|
|
21
|
-
expect(formatAnswer([], "some comment")).toBeNull();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
17
|
it("joins single part without separator", () => {
|
|
25
18
|
expect(formatAnswer(["yes"])).toBe("yes");
|
|
26
19
|
});
|
|
@@ -28,15 +21,6 @@ describe("formatAnswer", () => {
|
|
|
28
21
|
it("joins multiple parts with ', '", () => {
|
|
29
22
|
expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
|
|
30
23
|
});
|
|
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
|
-
});
|
|
40
24
|
});
|
|
41
25
|
|
|
42
26
|
describe("parseAnswerParts", () => {
|
|
@@ -44,7 +28,6 @@ describe("parseAnswerParts", () => {
|
|
|
44
28
|
const labels = ["yes", "no", "maybe"];
|
|
45
29
|
const result = parseAnswerParts("yes, no", labels);
|
|
46
30
|
expect(result.selected).toEqual(["yes", "no"]);
|
|
47
|
-
expect(result.comment).toBeUndefined();
|
|
48
31
|
});
|
|
49
32
|
|
|
50
33
|
// S5 核心:防子串误匹配——"A" 不应命中 label "AB"
|
|
@@ -69,39 +52,21 @@ describe("parseAnswerParts", () => {
|
|
|
69
52
|
expect(result.selected).toEqual(["C", "A"]);
|
|
70
53
|
});
|
|
71
54
|
|
|
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
|
-
|
|
80
55
|
it("handles full-width comma (,) as separator", () => {
|
|
81
56
|
const labels = ["A", "B"];
|
|
82
57
|
const result = parseAnswerParts("A,B", labels);
|
|
83
58
|
expect(result.selected).toEqual(["A", "B"]);
|
|
84
59
|
});
|
|
85
60
|
|
|
86
|
-
it("returns non-matching tokens as neither selected
|
|
61
|
+
it("returns non-matching tokens as neither selected (Other free text)", () => {
|
|
87
62
|
const labels = ["yes", "no"];
|
|
88
63
|
// "custom text" 不匹配任何 label → 是 Other 自由文本
|
|
89
64
|
const result = parseAnswerParts("custom text", labels);
|
|
90
65
|
expect(result.selected).toEqual([]);
|
|
91
|
-
expect(result.comment).toBeUndefined();
|
|
92
66
|
});
|
|
93
67
|
|
|
94
68
|
it("handles empty answer string", () => {
|
|
95
69
|
const result = parseAnswerParts("", ["A", "B"]);
|
|
96
70
|
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");
|
|
106
71
|
});
|
|
107
72
|
});
|
|
@@ -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 三种答案形态)。
|
|
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,12 +75,6 @@ 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
|
-
|
|
84
78
|
// ── Tests ───────────────────────────────────────────────
|
|
85
79
|
|
|
86
80
|
describe("createAskUserChannelHandler", () => {
|
|
@@ -103,19 +97,6 @@ describe("createAskUserChannelHandler", () => {
|
|
|
103
97
|
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
104
98
|
});
|
|
105
99
|
|
|
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
|
-
|
|
119
100
|
it("RPC: user cancel (select undefined) → {cancelled: true}", async () => {
|
|
120
101
|
const handler = createAskUserChannelHandler(
|
|
121
102
|
makeCtx({ mode: "rpc", selectResult: undefined }) as never,
|
|
@@ -217,21 +198,6 @@ describe("createAskUserChannelHandler", () => {
|
|
|
217
198
|
});
|
|
218
199
|
});
|
|
219
200
|
|
|
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
|
-
|
|
235
201
|
it("TUI: user cancel (custom returns null) → {cancelled: true}", async () => {
|
|
236
202
|
const handler = createAskUserChannelHandler(
|
|
237
203
|
makeCtx({ mode: "tui", customResult: null }) as never,
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
INSERT,
|
|
31
31
|
LEFT,
|
|
32
32
|
make,
|
|
33
|
-
multiQWithComment,
|
|
34
33
|
OSC_BEL,
|
|
35
34
|
OSC_ST,
|
|
36
35
|
PAGE_DOWN,
|
|
@@ -51,14 +50,6 @@ import {
|
|
|
51
50
|
UP,
|
|
52
51
|
} from "./fixtures";
|
|
53
52
|
|
|
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
|
-
|
|
62
53
|
/** Helper: 打开 freeform 编辑器并返回 component(不渲染,避免缓存) */
|
|
63
54
|
function openFreeform(q: Question[]): AskUserComponent {
|
|
64
55
|
const { c } = make(q);
|
|
@@ -264,25 +255,6 @@ describe("AskUserComponent — key leak fix (C-ARROW / C-KEYMAP)", () => {
|
|
|
264
255
|
expect(editorLine).not.toContain("abc");
|
|
265
256
|
});
|
|
266
257
|
|
|
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
|
-
|
|
286
258
|
// ── C-KEYMAP-MOD: modifier key combinations matrix (18 cases) ──
|
|
287
259
|
const modifierCases: Array<{ name: string; seq: string }> = [
|
|
288
260
|
{ name: "ctrl+up", seq: CTRL_UP },
|
|
@@ -429,18 +401,6 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
|
|
|
429
401
|
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
430
402
|
});
|
|
431
403
|
|
|
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
|
-
|
|
444
404
|
it("C-CSI-R1: plain text still appended correctly", () => {
|
|
445
405
|
const c = openFreeform([singleQ]);
|
|
446
406
|
c.handleInput("hello");
|
|
@@ -11,11 +11,9 @@ import {
|
|
|
11
11
|
LEFT,
|
|
12
12
|
mockTui,
|
|
13
13
|
multiQ,
|
|
14
|
-
multiQWithComment,
|
|
15
14
|
RIGHT,
|
|
16
15
|
singleQ,
|
|
17
16
|
singleQMulti,
|
|
18
|
-
singleQWithComment,
|
|
19
17
|
stubTheme,
|
|
20
18
|
TAB,
|
|
21
19
|
UP,
|
|
@@ -163,24 +161,20 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
163
161
|
const { c, result } = make([singleQMulti]);
|
|
164
162
|
// singleQMulti: [Auth, Search],光标初始在 Auth(0)
|
|
165
163
|
c.handleInput(DOWN); // 光标移到 Search(1),未 toggle
|
|
166
|
-
c.handleInput(ENTER); // Enter 应同时选中 Search + confirm
|
|
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(单问题)
|
|
164
|
+
c.handleInput(ENTER); // Enter 应同时选中 Search + confirm → submit(单问题)
|
|
171
165
|
expect(result.val!.answers["Which features?"]).toBe("Search");
|
|
172
166
|
});
|
|
173
167
|
|
|
174
|
-
it("C-S3: auto-confirm(←/→ 切 tab
|
|
175
|
-
// S-3
|
|
168
|
+
it("C-S3: auto-confirm(←/→ 切 tab)已答问题自动确认", () => {
|
|
169
|
+
// S-3 锁定:←/→ 切走时已答问题 auto-confirm
|
|
176
170
|
const twoQMulti: Question[] = [
|
|
177
|
-
{ question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true
|
|
171
|
+
{ question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
|
|
178
172
|
{ question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
|
|
179
173
|
];
|
|
180
174
|
const { c, result } = make(twoQMulti);
|
|
181
175
|
c.handleInput(" "); // Q1 toggle A
|
|
182
|
-
c.handleInput(RIGHT); // → Q2,auto-confirm Q1
|
|
183
|
-
// 验证:当前在 Q2
|
|
176
|
+
c.handleInput(RIGHT); // → Q2,auto-confirm Q1
|
|
177
|
+
// 验证:当前在 Q2。Q2 选 X → Submit
|
|
184
178
|
c.handleInput(ENTER); // Q2 select X → Submit
|
|
185
179
|
c.handleInput(ENTER); // Submit
|
|
186
180
|
expect(result.val!.answers["Q1"]).toBe("A"); // auto-confirm 生效
|
|
@@ -268,13 +262,14 @@ describe("AskUserComponent — multi-select toggle", () => {
|
|
|
268
262
|
expect(lines.some((l) => l.includes("[ ]") && l.includes("Auth"))).toBe(true);
|
|
269
263
|
});
|
|
270
264
|
|
|
271
|
-
it("C-24 (AC-18): multi-select toggle
|
|
272
|
-
// singleQMulti has allowComment:true + multiSelect:true
|
|
265
|
+
it("C-24 (AC-18): multi-select toggle stays in options mode (no editor)", () => {
|
|
273
266
|
const { c } = make([singleQMulti]);
|
|
274
|
-
c.handleInput(" "); // toggle —
|
|
267
|
+
c.handleInput(" "); // toggle — stays in options mode
|
|
275
268
|
const lines = c.render(60);
|
|
276
|
-
//
|
|
277
|
-
expect(lines.some((l) => l.
|
|
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);
|
|
278
273
|
});
|
|
279
274
|
});
|
|
280
275
|
|
|
@@ -472,108 +467,6 @@ describe("AskUserComponent — multi-char paste in editor", () => {
|
|
|
472
467
|
});
|
|
473
468
|
});
|
|
474
469
|
|
|
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
|
-
|
|
577
470
|
// ── 5g. 防重入(FR-12)─────────────────────────────────
|
|
578
471
|
describe("AskUserComponent — re-entry guard", () => {
|
|
579
472
|
it("C-40: ignores input after resolution (submit)", () => {
|
|
@@ -658,12 +551,12 @@ describe("AskUserComponent — Submit tab", () => {
|
|
|
658
551
|
});
|
|
659
552
|
|
|
660
553
|
it("C-46: Submit Enter when all confirmed submits", () => {
|
|
661
|
-
const { c, result } = make(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
//
|
|
666
|
-
c.handleInput(ENTER); // → Submit
|
|
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
|
|
667
560
|
c.handleInput(ENTER); // Submit
|
|
668
561
|
expect(result.val).not.toBeUndefined();
|
|
669
562
|
expect(result.val!.answers["Q1"]).toBe("A");
|
|
@@ -806,7 +699,7 @@ describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () =>
|
|
|
806
699
|
// ── 5l. 新行为:←/→ 不切 tab、Other Enter 切 freeform 原生、Submit tab focus ──
|
|
807
700
|
describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
808
701
|
it("C-NEW-1: multi-select Other + Enter opens freeform; Other row turns into [ ] <input>█ in-place", () => {
|
|
809
|
-
// singleQMulti: [Auth, Search],多选
|
|
702
|
+
// singleQMulti: [Auth, Search],多选
|
|
810
703
|
const { c, result } = make([singleQMulti]);
|
|
811
704
|
// 1) Space toggle Auth
|
|
812
705
|
c.handleInput(" ");
|
|
@@ -827,14 +720,13 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
827
720
|
expect(lines.some((l) => l.includes("Search"))).toBe(true);
|
|
828
721
|
// [✓] 标记的 Auth 仍存在(toggle 状态保留)
|
|
829
722
|
expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
|
|
830
|
-
// 4) 输 "redis" → Enter 保存 →
|
|
723
|
+
// 4) 输 "redis" → Enter 保存 → submit(单问题)
|
|
831
724
|
c.handleInput("r");
|
|
832
725
|
c.handleInput("e");
|
|
833
726
|
c.handleInput("d");
|
|
834
727
|
c.handleInput("i");
|
|
835
728
|
c.handleInput("s");
|
|
836
|
-
c.handleInput(ENTER); // 保存 freeText →
|
|
837
|
-
c.handleInput(ENTER); // 跳过评论 → submit(单问题)
|
|
729
|
+
c.handleInput(ENTER); // 保存 freeText → submit(单问题)
|
|
838
730
|
// 答案含多选 toggle 项 + Other 自定义
|
|
839
731
|
expect(result.val!.answers["Which features?"]).toBe("Auth, redis");
|
|
840
732
|
});
|
|
@@ -877,11 +769,12 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
877
769
|
});
|
|
878
770
|
|
|
879
771
|
it("C-NEW-4: Submit tab Enter on Submit focus (all confirmed) submits", () => {
|
|
880
|
-
|
|
881
|
-
|
|
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
|
+
]);
|
|
882
776
|
// 答完 Q1 + Q2
|
|
883
|
-
c.handleInput(ENTER); // Q1 select A →
|
|
884
|
-
c.handleInput(ENTER); // skip comment → Q2
|
|
777
|
+
c.handleInput(ENTER); // Q1 select A → Q2
|
|
885
778
|
c.handleInput(ENTER); // Q2 select X → Submit tab(Q2 是最后一个问题,advance 到 Submit)
|
|
886
779
|
// 已经在 Submit tab,focus=Submit,按 Enter 提交
|
|
887
780
|
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
|
|
10
|
+
// ── E2E-1: 单问题 — 选第二项提交 ────────────────────
|
|
11
|
+
describe("E2E-1: single question — pick 2nd option", () => {
|
|
12
12
|
const questions = [
|
|
13
13
|
{
|
|
14
14
|
question: "Which DB?",
|
|
@@ -32,53 +32,6 @@ describe("E2E-1: single question, no comment — 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
|
-
|
|
82
35
|
// ── E2E-4: 多问题提交 — 逐题选择后 Submit tab 提交(S-11)──────
|
|
83
36
|
describe("E2E-4: multi-question submit — answer each then Submit tab", () => {
|
|
84
37
|
const questions = [
|
|
@@ -42,7 +42,6 @@ export const singleQ: Question = {
|
|
|
42
42
|
|
|
43
43
|
export const singleQWithComment: Question = {
|
|
44
44
|
question: "Which DB? (with comment)",
|
|
45
|
-
allowComment: true,
|
|
46
45
|
options: [
|
|
47
46
|
{ label: "Postgres", description: "Battle-tested" },
|
|
48
47
|
{ label: "SQLite", description: "Embedded" },
|
|
@@ -52,7 +51,6 @@ export const singleQWithComment: Question = {
|
|
|
52
51
|
export const singleQMulti: Question = {
|
|
53
52
|
question: "Which features?",
|
|
54
53
|
multiSelect: true,
|
|
55
|
-
allowComment: true,
|
|
56
54
|
options: [
|
|
57
55
|
{ label: "Auth", description: "OAuth + session" },
|
|
58
56
|
{ label: "Search", description: "Full-text" },
|
|
@@ -71,7 +69,7 @@ export const multiQ: Question[] = [
|
|
|
71
69
|
];
|
|
72
70
|
|
|
73
71
|
export const multiQWithComment: Question[] = [
|
|
74
|
-
{ question: "Q1", header: "First",
|
|
72
|
+
{ question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
|
|
75
73
|
{ question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
|
|
76
74
|
];
|
|
77
75
|
|
|
@@ -645,31 +645,6 @@ 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
|
-
|
|
673
648
|
it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
|
|
674
649
|
const tool = getTool();
|
|
675
650
|
const result = await tool.execute(
|
|
@@ -721,7 +696,7 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
|
721
696
|
expect(result.details.answers["Which database?"]).toBe("Postgres");
|
|
722
697
|
});
|
|
723
698
|
|
|
724
|
-
it("R-8: multi-question mixed (single-select + multi-select + Other
|
|
699
|
+
it("R-8: multi-question mixed (single-select + multi-select + Other)", async () => {
|
|
725
700
|
const tool = getTool();
|
|
726
701
|
const mixed = {
|
|
727
702
|
questions: [
|
|
@@ -740,7 +715,6 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
|
740
715
|
question: "Which region?",
|
|
741
716
|
header: "Region",
|
|
742
717
|
options: [{ label: "US" }, { label: "EU" }],
|
|
743
|
-
allowComment: true,
|
|
744
718
|
},
|
|
745
719
|
],
|
|
746
720
|
};
|
|
@@ -769,40 +743,4 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
|
769
743
|
// Q3: 无选中 → 跳过(不在 answers map 中)
|
|
770
744
|
expect(result.details.answers["Which region?"]).toBeUndefined();
|
|
771
745
|
});
|
|
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
|
-
});
|
|
808
746
|
});
|
|
@@ -252,7 +252,7 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
252
252
|
});
|
|
253
253
|
|
|
254
254
|
it("Q-28-WIDE: freeform 模式在宽终端下用全宽渲染(不被分屏左列压窄)", () => {
|
|
255
|
-
// 回归:freeform
|
|
255
|
+
// 回归:freeform 模式忽略分屏,编辑器用全 width。
|
|
256
256
|
// 修复前:宽终端走 split.left(≈40),Other 输入被压在左半屏换行频繁。
|
|
257
257
|
const width = 100;
|
|
258
258
|
// getSplitPaneWidths(100) 非 null(宽终端会进分屏分支),但 freeform 应绕过它
|
|
@@ -335,35 +335,6 @@ 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
|
-
|
|
367
338
|
// ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
|
|
368
339
|
describe("renderQuestionView — help line", () => {
|
|
369
340
|
it("Q-20: single-select help shows 'Enter select'", () => {
|
|
@@ -124,11 +124,6 @@ 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
|
-
|
|
132
127
|
it("S-10: unconfirmed returns null", () => {
|
|
133
128
|
const s = makeState({ confirmed: false });
|
|
134
129
|
expect(getAnswerText(q1, s)).toBeNull();
|
|
@@ -148,21 +143,6 @@ describe("getAnswerText", () => {
|
|
|
148
143
|
const sEmptyMulti = makeState({ confirmed: true, selectedIndices: new Set<number>() });
|
|
149
144
|
expect(getAnswerText(multiQ, sEmptyMulti)).toBeNull();
|
|
150
145
|
});
|
|
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
|
-
});
|
|
166
146
|
});
|
|
167
147
|
|
|
168
148
|
// ── S-11 ~ S-12: buildResult ─────────────────────────────
|
|
@@ -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)
|
|
86
86
|
it("rejects duplicate headers across different questions", () => {
|
|
87
87
|
const result = validateInput([
|
|
88
88
|
q({ question: "Q1", header: "Same" }),
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
multiQ,
|
|
14
14
|
RIGHT,
|
|
15
15
|
singleQ,
|
|
16
|
-
singleQWithComment,
|
|
17
16
|
stubTheme,
|
|
18
17
|
UP,
|
|
19
18
|
} from "./fixtures";
|
|
@@ -78,15 +77,6 @@ describe("W2 — draftText migration", () => {
|
|
|
78
77
|
expect(editorLine1).toContain("aaa");
|
|
79
78
|
expect(editorLine1).not.toContain("ccc");
|
|
80
79
|
});
|
|
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
|
-
});
|
|
90
80
|
});
|
|
91
81
|
|
|
92
82
|
// ── hint line: append-only UX hint ──
|
|
@@ -102,16 +92,6 @@ describe("W2 — hint line", () => {
|
|
|
102
92
|
expect(hintLine).toContain("Enter submit");
|
|
103
93
|
expect(hintLine).toContain("Esc back");
|
|
104
94
|
});
|
|
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
|
-
});
|
|
115
95
|
});
|
|
116
96
|
|
|
117
97
|
// ── freeDraft 隔离:丢弃的 freeform 草稿不污染答案、不触发 auto-confirm ──
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/__tests__/w3-regression.test.ts
|
|
2
|
-
// W3: Forward regression — freeform/
|
|
2
|
+
// W3: Forward regression — freeform/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,7 +14,6 @@ import {
|
|
|
14
14
|
LEFT,
|
|
15
15
|
mockTui,
|
|
16
16
|
multiQ,
|
|
17
|
-
multiQWithComment,
|
|
18
17
|
RIGHT,
|
|
19
18
|
singleQ,
|
|
20
19
|
stubTheme,
|
|
@@ -72,33 +71,6 @@ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
|
|
|
72
71
|
});
|
|
73
72
|
});
|
|
74
73
|
|
|
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
|
-
|
|
102
74
|
// ── C-BKSP-EDGE: backspace at cursorIndex=0 ──
|
|
103
75
|
|
|
104
76
|
describe("W3 — backspace at cursorIndex=0 (C-BKSP-EDGE)", () => {
|
package/src/answer-format.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
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" 格式,确保两条路径一致。
|
|
5
5
|
// renderExpandedOptions(index.ts)调 parseAnswerParts 精确反解析选中项。
|
|
6
|
-
import { ANSWER_COMMENT_SEPARATOR } from "./types";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
* 把答案各部分拼装为最终文本格式:"part1, part2
|
|
8
|
+
* 把答案各部分拼装为最终文本格式:"part1, part2"。
|
|
10
9
|
* - parts 为空 → 返回 null(未答)
|
|
11
|
-
* - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
|
|
12
10
|
*/
|
|
13
|
-
export function formatAnswer(parts: string[]
|
|
11
|
+
export function formatAnswer(parts: string[]): string | null {
|
|
14
12
|
if (parts.length === 0) return null;
|
|
15
|
-
|
|
16
|
-
return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
|
|
13
|
+
return parts.join(", ");
|
|
17
14
|
}
|
|
18
15
|
|
|
19
16
|
/**
|
|
@@ -22,24 +19,15 @@ export function formatAnswer(parts: string[], comment?: string | null): string |
|
|
|
22
19
|
*
|
|
23
20
|
* @param answer 最终答案文本(formatAnswer 产出)
|
|
24
21
|
* @param labels 候选 label 列表(q.options 的 label),精确匹配
|
|
25
|
-
* @returns selected=命中的 labels(按 answer
|
|
22
|
+
* @returns selected=命中的 labels(按 answer 中出现顺序)
|
|
26
23
|
*/
|
|
27
24
|
export function parseAnswerParts(
|
|
28
25
|
answer: string,
|
|
29
26
|
labels: string[],
|
|
30
|
-
): { selected: string[]
|
|
31
|
-
//
|
|
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
|
|
27
|
+
): { selected: string[] } {
|
|
28
|
+
// answer 形如 "label1, label2" → 精确匹配候选 label
|
|
41
29
|
const labelSet = new Set(labels);
|
|
42
|
-
const tokens =
|
|
30
|
+
const tokens = answer.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
43
31
|
const selected: string[] = [];
|
|
44
32
|
// 剩余 tokens 不匹配任何 label → 是 Other 自由文本(不返回,调用方自行处理)
|
|
45
33
|
for (const token of tokens) {
|
|
@@ -47,5 +35,5 @@ export function parseAnswerParts(
|
|
|
47
35
|
selected.push(token);
|
|
48
36
|
}
|
|
49
37
|
}
|
|
50
|
-
return { selected
|
|
38
|
+
return { selected };
|
|
51
39
|
}
|
package/src/channel-handler.ts
CHANGED
|
@@ -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
|
|
13
|
-
// 单选=value,多选=JSON 数组,Other→__other
|
|
12
|
+
// value="label1, label2")→ 重新编码为 proto AskUserAnswers(key=header/question,
|
|
13
|
+
// 单选=value,多选=JSON 数组,Other→__other),让子进程 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 {
|
|
26
|
+
import { type Option, type Question, type Result, type ThemeLike } from "./types";
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
|
|
@@ -59,7 +59,6 @@ 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 } : {}),
|
|
63
62
|
};
|
|
64
63
|
});
|
|
65
64
|
}
|
|
@@ -67,18 +66,17 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
|
|
|
67
66
|
/**
|
|
68
67
|
* 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
|
|
69
68
|
*
|
|
70
|
-
* 内部 Result.answers:key = question 全文,value = "label1, label2
|
|
71
|
-
* (Other 自由文本与 selected
|
|
69
|
+
* 内部 Result.answers:key = question 全文,value = "label1, label2"
|
|
70
|
+
* (Other 自由文本与 selected 标签逗号拼接)。
|
|
72
71
|
*
|
|
73
72
|
* proto AskUserAnswers 契约(@xyz-agent/extension-protocol):
|
|
74
73
|
* - key = question.header ?? question 全文
|
|
75
74
|
* - 单选:value = 选中项 value string
|
|
76
75
|
* - 多选:value = JSON.stringify(选中项 value 数组)
|
|
77
76
|
* - Other 自由文本:单独 key `${header}__other`
|
|
78
|
-
* - comment:单独 key `${header}__comment`
|
|
79
77
|
*
|
|
80
78
|
* 解码(无信息丢失):用 protoQuestion.options 的 label 集合精确匹配 selected;
|
|
81
|
-
* 不匹配的 token = Other
|
|
79
|
+
* 不匹配的 token = Other 自由文本。
|
|
82
80
|
*/
|
|
83
81
|
function encodeTuiResultToProto(
|
|
84
82
|
protoQuestions: AskUserQuestion[],
|
|
@@ -97,15 +95,8 @@ function encodeTuiResultToProto(
|
|
|
97
95
|
if (o.value !== undefined) knownLabels.add(o.value);
|
|
98
96
|
}
|
|
99
97
|
|
|
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
|
-
|
|
107
98
|
// body tokens:匹配 knownLabels 的为 selected,其余为 Other 自由文本
|
|
108
|
-
const tokens =
|
|
99
|
+
const tokens = internalText.split(/[,,]/).map((t: string) => t.trim()).filter((t: string) => t !== "");
|
|
109
100
|
const selected: string[] = [];
|
|
110
101
|
const otherTokens: string[] = [];
|
|
111
102
|
for (const t of tokens) {
|
|
@@ -130,7 +121,6 @@ function encodeTuiResultToProto(
|
|
|
130
121
|
}
|
|
131
122
|
|
|
132
123
|
if (otherText) answers[`${key}__other`] = otherText;
|
|
133
|
-
if (comment) answers[`${key}__comment`] = comment;
|
|
134
124
|
}
|
|
135
125
|
return answers;
|
|
136
126
|
}
|
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
|
+
/** 选中确认后的处理:直接前进到下一题(或单问题提交)。 */
|
|
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,7 +7,6 @@ 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
|
|
|
@@ -128,15 +127,14 @@ function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
|
|
|
128
127
|
})),
|
|
129
128
|
multiSelect: q.multiSelect,
|
|
130
129
|
allowOther: true,
|
|
131
|
-
allowComment: q.allowComment ?? false,
|
|
132
130
|
}));
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
/**
|
|
136
134
|
* 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
|
|
137
135
|
*
|
|
138
|
-
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other
|
|
139
|
-
* ask-user 格式:key=question 全文, value=逗号分隔 label + Other
|
|
136
|
+
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other
|
|
137
|
+
* ask-user 格式:key=question 全文, value=逗号分隔 label + Other
|
|
140
138
|
*
|
|
141
139
|
* 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
|
|
142
140
|
* 确保 RPC 和 TUI 两条路径产出的 Result.answers 格式一致。
|
|
@@ -152,7 +150,6 @@ function protoAnswersToResult(
|
|
|
152
150
|
const iq = protoQuestions[i]!;
|
|
153
151
|
const selected = getAskUserAnswer(answers, iq);
|
|
154
152
|
const other = getAskUserOther(answers, iq);
|
|
155
|
-
const comment = getAskUserComment(answers, iq);
|
|
156
153
|
|
|
157
154
|
const parts: string[] = [];
|
|
158
155
|
if (Array.isArray(selected)) {
|
|
@@ -170,7 +167,7 @@ function protoAnswersToResult(
|
|
|
170
167
|
parts.push(selected);
|
|
171
168
|
}
|
|
172
169
|
if (other) parts.push(other);
|
|
173
|
-
const formatted = formatAnswer(parts
|
|
170
|
+
const formatted = formatAnswer(parts);
|
|
174
171
|
if (formatted !== null) out[q.question] = formatted;
|
|
175
172
|
}
|
|
176
173
|
return out;
|
package/src/question-view.ts
CHANGED
|
@@ -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 =
|
|
139
|
+
const activeOptionCursor = state.mode === "freeform" ? 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,33 +224,6 @@ 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
|
-
|
|
254
227
|
/** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
|
|
255
228
|
function buildSplitPane(
|
|
256
229
|
ctx: RenderContext,
|
|
@@ -302,21 +275,20 @@ 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
|
-
//
|
|
282
|
+
// 编辑器模式:选项列表 + 原地编辑器行(freeform 模式下 buildOptionLines 原地渲染)。
|
|
310
283
|
// 编辑器模式一律用全 width 单列渲染——分屏左列仅约 42% 宽,Other 自由输入会被压窄换行,
|
|
311
284
|
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
312
|
-
if (state.mode === "freeform"
|
|
285
|
+
if (state.mode === "freeform") {
|
|
313
286
|
add("");
|
|
314
287
|
for (const line of buildOptionLines(ctx, false)) add(line);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
288
|
+
// 选项列表与 help 行之间的视觉空隙(旧 buildEditorBlock freeform 分支返回 [""] 提供)
|
|
289
|
+
add("");
|
|
290
|
+
// freeform 模式 help 行:光标锁在 Other 上,正在输入
|
|
291
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
320
292
|
return lines;
|
|
321
293
|
}
|
|
322
294
|
|
package/src/submit-view.ts
CHANGED
|
@@ -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);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
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 = " — ";
|
|
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)." }),
|
|
52
|
-
),
|
|
53
49
|
});
|
|
54
50
|
|
|
55
51
|
/**
|
|
@@ -123,7 +119,7 @@ export interface ThemeLike {
|
|
|
123
119
|
}
|
|
124
120
|
|
|
125
121
|
/** 单问题的交互模式 */
|
|
126
|
-
export type QuestionMode = "options" | "freeform"
|
|
122
|
+
export type QuestionMode = "options" | "freeform";
|
|
127
123
|
|
|
128
124
|
/** 单问题的交互状态(每问题一个实例) */
|
|
129
125
|
export interface QuestionState {
|
|
@@ -140,8 +136,6 @@ export interface QuestionState {
|
|
|
140
136
|
/** freeform Esc 保存的未提交草稿;null=无草稿。
|
|
141
137
|
* 与 freeTextValue(已提交答案)分离,避免放弃的草稿污染答案、触发 auto-confirm。 */
|
|
142
138
|
freeDraft: string | null;
|
|
143
|
-
/** 可选评论;null=未输入 */
|
|
144
|
-
commentValue: string | null;
|
|
145
139
|
/** 当前交互模式 */
|
|
146
140
|
mode: QuestionMode;
|
|
147
141
|
/** 编辑器草稿文本(每问题独立持有,进编辑器时预填、退出时清空) */
|
|
@@ -159,7 +153,6 @@ export function createQuestionState(): QuestionState {
|
|
|
159
153
|
confirmed: false,
|
|
160
154
|
freeTextValue: null,
|
|
161
155
|
freeDraft: null,
|
|
162
|
-
commentValue: null,
|
|
163
156
|
mode: "options",
|
|
164
157
|
draftText: "",
|
|
165
158
|
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
|
}
|