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