@zhushanwen/pi-ask-user 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +305 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/validate.test.ts +5 -3
- package/src/__tests__/w2-draft-hint.test.ts +0 -89
- package/src/__tests__/w3-regression.test.ts +5 -187
- package/src/answer-format.ts +51 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +193 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"README.md",
|
|
26
26
|
"ARCHITECTURE.md"
|
|
27
27
|
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.2.0"
|
|
30
|
+
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@earendil-works/pi-tui": "*",
|
|
30
33
|
"@sinclair/typebox": "*",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/__tests__/answer-format.test.ts
|
|
2
|
+
//
|
|
3
|
+
// answer-format.ts 独立单元测试。
|
|
4
|
+
// 覆盖审查 S5 发现的覆盖盲区:
|
|
5
|
+
// - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
|
|
6
|
+
// - formatAnswer 空 parts → null
|
|
7
|
+
// - comment 分隔符边界
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { formatAnswer, parseAnswerParts } from "../answer-format.js";
|
|
12
|
+
import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
|
|
13
|
+
|
|
14
|
+
describe("formatAnswer", () => {
|
|
15
|
+
it("returns null for empty parts (unanswered)", () => {
|
|
16
|
+
expect(formatAnswer([])).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null for empty parts even with comment", () => {
|
|
20
|
+
// parts 空 = 没有选中选项,即使有 comment 也不应产出有效答案行
|
|
21
|
+
expect(formatAnswer([], "some comment")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("joins single part without separator", () => {
|
|
25
|
+
expect(formatAnswer(["yes"])).toBe("yes");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("joins multiple parts with ', '", () => {
|
|
29
|
+
expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
|
|
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
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseAnswerParts", () => {
|
|
43
|
+
it("extracts selected labels by exact match", () => {
|
|
44
|
+
const labels = ["yes", "no", "maybe"];
|
|
45
|
+
const result = parseAnswerParts("yes, no", labels);
|
|
46
|
+
expect(result.selected).toEqual(["yes", "no"]);
|
|
47
|
+
expect(result.comment).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// S5 核心:防子串误匹配——"A" 不应命中 label "AB"
|
|
51
|
+
it("does NOT match substring labels (A vs AB)", () => {
|
|
52
|
+
const labels = ["A", "AB", "ABC"];
|
|
53
|
+
// 答案 "A, AB" 应精确匹配两个 label,而非 "A" 匹配三次
|
|
54
|
+
const result = parseAnswerParts("A, AB", labels);
|
|
55
|
+
expect(result.selected).toEqual(["A", "AB"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT match 'A' when only 'AB' is in answer", () => {
|
|
59
|
+
const labels = ["A", "AB"];
|
|
60
|
+
// 答案 "AB" 只应命中 "AB",不应命中 "A"
|
|
61
|
+
const result = parseAnswerParts("AB", labels);
|
|
62
|
+
expect(result.selected).toEqual(["AB"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves order of appearance in answer (not label order)", () => {
|
|
66
|
+
const labels = ["A", "B", "C"];
|
|
67
|
+
// 用户选择顺序可能与 options 定义顺序不同
|
|
68
|
+
const result = parseAnswerParts("C, A", labels);
|
|
69
|
+
expect(result.selected).toEqual(["C", "A"]);
|
|
70
|
+
});
|
|
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
|
+
|
|
80
|
+
it("handles full-width comma (,) as separator", () => {
|
|
81
|
+
const labels = ["A", "B"];
|
|
82
|
+
const result = parseAnswerParts("A,B", labels);
|
|
83
|
+
expect(result.selected).toEqual(["A", "B"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns non-matching tokens as neither selected nor comment (Other free text)", () => {
|
|
87
|
+
const labels = ["yes", "no"];
|
|
88
|
+
// "custom text" 不匹配任何 label → 是 Other 自由文本
|
|
89
|
+
const result = parseAnswerParts("custom text", labels);
|
|
90
|
+
expect(result.selected).toEqual([]);
|
|
91
|
+
expect(result.comment).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles empty answer string", () => {
|
|
95
|
+
const result = parseAnswerParts("", ["A", "B"]);
|
|
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");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -473,37 +473,6 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
|
|
|
473
473
|
expect(editorLine).toContain("hello");
|
|
474
474
|
expect(editorLine).not.toContain("[200~");
|
|
475
475
|
});
|
|
476
|
-
|
|
477
|
-
it("C-CSI-R5: arrow keys still no-op", () => {
|
|
478
|
-
const c = openFreeform([singleQ]);
|
|
479
|
-
c.handleInput(RIGHT);
|
|
480
|
-
c.handleInput(RIGHT);
|
|
481
|
-
c.handleInput(RIGHT);
|
|
482
|
-
c.handleInput("a");
|
|
483
|
-
c.handleInput("b");
|
|
484
|
-
const lines = c.render(60);
|
|
485
|
-
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
486
|
-
expect(editorLine).toContain("ab");
|
|
487
|
-
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it("C-CSI-R6: backspace still works", () => {
|
|
491
|
-
const c = openFreeform([singleQ]);
|
|
492
|
-
c.handleInput("abc");
|
|
493
|
-
c.handleInput(BKSP);
|
|
494
|
-
const lines = c.render(60);
|
|
495
|
-
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
496
|
-
expect(editorLine).toContain("ab");
|
|
497
|
-
expect(editorLine).not.toContain("abc");
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
it("C-CSI-R7: Esc still exits editor", () => {
|
|
501
|
-
const c = openFreeform([singleQ]);
|
|
502
|
-
c.handleInput("abc");
|
|
503
|
-
c.handleInput(ESC);
|
|
504
|
-
const lines = c.render(60);
|
|
505
|
-
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
|
|
506
|
-
});
|
|
507
476
|
});
|
|
508
477
|
|
|
509
478
|
// 🐛 = U+1F41B,UTF-16 surrogate pair(占 2 个 code unit,index 1=高代理 / 2=低代理)。
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/__tests__/editor-ops.test.ts
|
|
2
|
+
//
|
|
3
|
+
// editor-ops.ts 独立单元测试。
|
|
4
|
+
// 覆盖审查 S5 发现的 surrogate pair 边界盲区:
|
|
5
|
+
// - moveCursorLeft/Right 在 emoji(surrogate pair)边界正确按 code point 移动
|
|
6
|
+
// - deleteCharBeforeCursor 删整个 emoji code point
|
|
7
|
+
//
|
|
8
|
+
// 之前仅靠 component.test.ts 黑盒间接覆盖,无法直接锁定 surrogate 行为。
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
deleteCharBeforeCursor,
|
|
14
|
+
handleEditorPaste,
|
|
15
|
+
insertAtCursor,
|
|
16
|
+
moveCursorEnd,
|
|
17
|
+
moveCursorHome,
|
|
18
|
+
moveCursorLeft,
|
|
19
|
+
moveCursorRight,
|
|
20
|
+
} from "../editor-ops.js";
|
|
21
|
+
import { createQuestionState, type QuestionState } from "../types.js";
|
|
22
|
+
|
|
23
|
+
function stateWith(text: string, cursor = text.length): QuestionState {
|
|
24
|
+
const s = createQuestionState();
|
|
25
|
+
s.draftText = text;
|
|
26
|
+
s.cursorIndex = cursor;
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("moveCursorLeft", () => {
|
|
31
|
+
it("moves left by 1 for ASCII", () => {
|
|
32
|
+
const s = stateWith("abc", 3);
|
|
33
|
+
moveCursorLeft(s);
|
|
34
|
+
expect(s.cursorIndex).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("moves left by 1 code point (2 UTF-16 units) for emoji", () => {
|
|
38
|
+
// "a😀b" → length=4 (a=1, 😀=2, b=1),光标在末尾(4)
|
|
39
|
+
const s = stateWith("a😀b", 4);
|
|
40
|
+
moveCursorLeft(s); // 从 4 → 跳过 b 到 3
|
|
41
|
+
expect(s.cursorIndex).toBe(3);
|
|
42
|
+
moveCursorLeft(s); // 从 3 → 跳过 emoji 到 1(不是 2)
|
|
43
|
+
expect(s.cursorIndex).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not go below 0", () => {
|
|
47
|
+
const s = stateWith("abc", 0);
|
|
48
|
+
moveCursorLeft(s);
|
|
49
|
+
expect(s.cursorIndex).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stops at 0 after emoji", () => {
|
|
53
|
+
const s = stateWith("😀", 2);
|
|
54
|
+
moveCursorLeft(s);
|
|
55
|
+
expect(s.cursorIndex).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("moveCursorRight", () => {
|
|
60
|
+
it("moves right by 1 for ASCII", () => {
|
|
61
|
+
const s = stateWith("abc", 0);
|
|
62
|
+
moveCursorRight(s);
|
|
63
|
+
expect(s.cursorIndex).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("moves right by 1 code point (2 UTF-16 units) for emoji", () => {
|
|
67
|
+
// "a😀b" → 光标在 0,右移应到 1(a),再到 3(跳过 emoji 的 2 个 unit)
|
|
68
|
+
const s = stateWith("a😀b", 0);
|
|
69
|
+
moveCursorRight(s); // 0 → 1
|
|
70
|
+
expect(s.cursorIndex).toBe(1);
|
|
71
|
+
moveCursorRight(s); // 1 → 3(跳过 😀 的 2 个 unit)
|
|
72
|
+
expect(s.cursorIndex).toBe(3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not exceed draftText.length", () => {
|
|
76
|
+
const s = stateWith("ab", 2);
|
|
77
|
+
moveCursorRight(s);
|
|
78
|
+
expect(s.cursorIndex).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("stops at end after emoji", () => {
|
|
82
|
+
const s = stateWith("x😀", 1);
|
|
83
|
+
moveCursorRight(s);
|
|
84
|
+
expect(s.cursorIndex).toBe(3); // 跳到末尾
|
|
85
|
+
moveCursorRight(s); // 不超出
|
|
86
|
+
expect(s.cursorIndex).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("deleteCharBeforeCursor", () => {
|
|
91
|
+
it("deletes single ASCII char", () => {
|
|
92
|
+
const s = stateWith("abc", 2);
|
|
93
|
+
const changed = deleteCharBeforeCursor(s);
|
|
94
|
+
expect(changed).toBe(true);
|
|
95
|
+
expect(s.draftText).toBe("ac");
|
|
96
|
+
expect(s.cursorIndex).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("deletes entire emoji code point (2 UTF-16 units)", () => {
|
|
100
|
+
// "a😀b" 光标在 4(末尾),删除应删掉 b → "a😀"
|
|
101
|
+
const s = stateWith("a😀b", 4);
|
|
102
|
+
deleteCharBeforeCursor(s);
|
|
103
|
+
expect(s.draftText).toBe("a😀");
|
|
104
|
+
expect(s.cursorIndex).toBe(3);
|
|
105
|
+
// 再删一次应删掉整个 emoji(2 units),不是只删 1
|
|
106
|
+
deleteCharBeforeCursor(s);
|
|
107
|
+
expect(s.draftText).toBe("a");
|
|
108
|
+
expect(s.cursorIndex).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns false at cursor 0 (nothing to delete)", () => {
|
|
112
|
+
const s = stateWith("abc", 0);
|
|
113
|
+
const changed = deleteCharBeforeCursor(s);
|
|
114
|
+
expect(changed).toBe(false);
|
|
115
|
+
expect(s.draftText).toBe("abc");
|
|
116
|
+
expect(s.cursorIndex).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("deletes emoji at start of text", () => {
|
|
120
|
+
const s = stateWith("😀hello", 2);
|
|
121
|
+
deleteCharBeforeCursor(s);
|
|
122
|
+
expect(s.draftText).toBe("hello");
|
|
123
|
+
expect(s.cursorIndex).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("insertAtCursor", () => {
|
|
128
|
+
it("inserts text at cursor position", () => {
|
|
129
|
+
const s = stateWith("abc", 1);
|
|
130
|
+
insertAtCursor(s, "X");
|
|
131
|
+
expect(s.draftText).toBe("aXbc");
|
|
132
|
+
expect(s.cursorIndex).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("moveCursorHome / moveCursorEnd", () => {
|
|
137
|
+
it("home sets cursor to 0", () => {
|
|
138
|
+
const s = stateWith("abc", 2);
|
|
139
|
+
moveCursorHome(s);
|
|
140
|
+
expect(s.cursorIndex).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("end sets cursor to draftText.length", () => {
|
|
144
|
+
const s = stateWith("a😀b", 0);
|
|
145
|
+
moveCursorEnd(s);
|
|
146
|
+
expect(s.cursorIndex).toBe(4);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("handleEditorPaste", () => {
|
|
151
|
+
it("inserts printable text", () => {
|
|
152
|
+
const s = stateWith("ab", 1);
|
|
153
|
+
const changed = handleEditorPaste(s, "XY");
|
|
154
|
+
expect(changed).toBe(true);
|
|
155
|
+
expect(s.draftText).toBe("aXYb");
|
|
156
|
+
expect(s.cursorIndex).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("inserts emoji correctly (surrogate pair via Array.from)", () => {
|
|
160
|
+
const s = stateWith("", 0);
|
|
161
|
+
handleEditorPaste(s, "😀");
|
|
162
|
+
expect(s.draftText).toBe("😀");
|
|
163
|
+
expect(s.cursorIndex).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects unknown escape sequences (non-bracketed)", () => {
|
|
167
|
+
const s = stateWith("ab", 1);
|
|
168
|
+
const changed = handleEditorPaste(s, "\x1b[6n");
|
|
169
|
+
expect(changed).toBe(false);
|
|
170
|
+
expect(s.draftText).toBe("ab");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("strips bracketed paste markers", () => {
|
|
174
|
+
const s = stateWith("", 0);
|
|
175
|
+
const changed = handleEditorPaste(s, "\x1b[200~hello\x1b[201~");
|
|
176
|
+
expect(changed).toBe(true);
|
|
177
|
+
expect(s.draftText).toBe("hello");
|
|
178
|
+
});
|
|
179
|
+
});
|