@bachi/pi-coder 1.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model.ts — 问卷的纯状态机(不 import pi / pi-tui),view.ts 只是它的一层
|
|
3
|
+
* 按键解码 + 渲染外壳。所有交互语义(tab 切换、光标、多选勾选、单选/多选/
|
|
4
|
+
* 自由输入的提交、提交页闸门、取消)都集中在这里,`node --test` 直接覆盖。
|
|
5
|
+
*
|
|
6
|
+
* 状态约定:
|
|
7
|
+
* - `currentTab` 0..n-1 是问题页;`n`(=questions.length)是提交页,**只有
|
|
8
|
+
* 多题(n>1)才有提交页**——单题选完即提交,没有可切换的页。
|
|
9
|
+
* - `cursor` 是当前问题页内的行号:0..options.length-1 是模型给的选项,
|
|
10
|
+
* options.length 是自动追加的 "Type something." 哨兵行。
|
|
11
|
+
* - `toggles[i][j]` 是第 i 题多选下第 j 个选项的勾选态(单选不用)。
|
|
12
|
+
* - `answers[i]` 是第 i 题已提交的答案;重答覆盖旧值。
|
|
13
|
+
*
|
|
14
|
+
* reduce 原地改 state、返回 Outcome 告诉外壳下一步做什么(继续 / 进自由输入 /
|
|
15
|
+
* 出自由输入 / 提交 / 取消)。提交与取消的 result 都是**已答部分**的快照。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AskAnswer, AskParams, AskQuestion, AskResult } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
export interface QuestionnaireState {
|
|
21
|
+
readonly questions: AskQuestion[];
|
|
22
|
+
currentTab: number;
|
|
23
|
+
cursor: number;
|
|
24
|
+
inputMode: boolean;
|
|
25
|
+
answers: Array<AskAnswer | undefined>;
|
|
26
|
+
toggles: boolean[][];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Intent =
|
|
30
|
+
| { type: "up" }
|
|
31
|
+
| { type: "down" }
|
|
32
|
+
| { type: "nextTab" }
|
|
33
|
+
| { type: "prevTab" }
|
|
34
|
+
| { type: "toggle" }
|
|
35
|
+
| { type: "digit"; digit: number }
|
|
36
|
+
| { type: "confirm" }
|
|
37
|
+
| { type: "cancel" }
|
|
38
|
+
| { type: "inputSubmit"; text: string }
|
|
39
|
+
| { type: "inputCancel" };
|
|
40
|
+
|
|
41
|
+
export type Outcome =
|
|
42
|
+
| { status: "continue" }
|
|
43
|
+
| { status: "enterInput" }
|
|
44
|
+
| { status: "exitInput" }
|
|
45
|
+
| { status: "submit"; result: AskResult }
|
|
46
|
+
| { status: "cancel"; result: AskResult };
|
|
47
|
+
|
|
48
|
+
const CONTINUE: Outcome = { status: "continue" };
|
|
49
|
+
|
|
50
|
+
export function createState(params: AskParams): QuestionnaireState {
|
|
51
|
+
return {
|
|
52
|
+
questions: params.questions,
|
|
53
|
+
currentTab: 0,
|
|
54
|
+
cursor: 0,
|
|
55
|
+
inputMode: false,
|
|
56
|
+
answers: params.questions.map(() => undefined),
|
|
57
|
+
toggles: params.questions.map((q) => q.options.map(() => false)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 多题才有提交页。 */
|
|
62
|
+
export function hasSubmitTab(state: QuestionnaireState): boolean {
|
|
63
|
+
return state.questions.length > 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isSubmitTab(state: QuestionnaireState): boolean {
|
|
67
|
+
return hasSubmitTab(state) && state.currentTab === state.questions.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 当前问题页的行数 = 选项数 + 1(哨兵行)。提交页无意义,返回 0。 */
|
|
71
|
+
export function rowCount(state: QuestionnaireState): number {
|
|
72
|
+
const q = state.questions[state.currentTab];
|
|
73
|
+
return q ? q.options.length + 1 : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function allAnswered(state: QuestionnaireState): boolean {
|
|
77
|
+
return state.answers.every((a) => a !== undefined);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 未回答问题的 header 列表(提交页提示用)。 */
|
|
81
|
+
export function unansweredHeaders(state: QuestionnaireState): string[] {
|
|
82
|
+
const headers: string[] = [];
|
|
83
|
+
for (let i = 0; i < state.questions.length; i++) {
|
|
84
|
+
if (state.answers[i] === undefined) headers.push(state.questions[i].header || `Q${i + 1}`);
|
|
85
|
+
}
|
|
86
|
+
return headers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 已答部分(去掉 undefined)的快照,按题序。 */
|
|
90
|
+
function compactAnswers(state: QuestionnaireState): AskAnswer[] {
|
|
91
|
+
return state.answers.filter((a): a is AskAnswer => a !== undefined);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function currentQuestion(state: QuestionnaireState): AskQuestion | undefined {
|
|
95
|
+
return state.questions[state.currentTab];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function selectedLabels(state: QuestionnaireState): string[] {
|
|
99
|
+
const q = currentQuestion(state);
|
|
100
|
+
if (!q) return [];
|
|
101
|
+
const toggles = state.toggles[state.currentTab];
|
|
102
|
+
const labels: string[] = [];
|
|
103
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
104
|
+
if (toggles[i]) labels.push(q.options[i].label);
|
|
105
|
+
}
|
|
106
|
+
return labels;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 提交当前题后前进:单题直接提交;多题去下一题,最后一题去提交页。 */
|
|
110
|
+
function advance(state: QuestionnaireState): Outcome {
|
|
111
|
+
if (state.questions.length === 1) {
|
|
112
|
+
return { status: "submit", result: { answers: compactAnswers(state), cancelled: false } };
|
|
113
|
+
}
|
|
114
|
+
if (state.currentTab < state.questions.length - 1) {
|
|
115
|
+
focusTab(state, state.currentTab + 1);
|
|
116
|
+
} else {
|
|
117
|
+
state.currentTab = state.questions.length; // 提交页
|
|
118
|
+
state.cursor = 0;
|
|
119
|
+
}
|
|
120
|
+
return CONTINUE;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 切到某页并按已有答案恢复光标位置(重答时停在原选择上)。 */
|
|
124
|
+
function focusTab(state: QuestionnaireState, tab: number): void {
|
|
125
|
+
state.currentTab = tab;
|
|
126
|
+
state.cursor = 0;
|
|
127
|
+
state.inputMode = false;
|
|
128
|
+
const q = state.questions[tab];
|
|
129
|
+
const answer = state.answers[tab];
|
|
130
|
+
if (!q || !answer) return;
|
|
131
|
+
if (answer.kind === "custom") {
|
|
132
|
+
state.cursor = q.options.length; // 哨兵行
|
|
133
|
+
} else if (answer.kind === "option" && answer.answer != null) {
|
|
134
|
+
const idx = q.options.findIndex((o) => o.label === answer.answer);
|
|
135
|
+
if (idx >= 0) state.cursor = idx;
|
|
136
|
+
} else if (answer.kind === "multi" && answer.selected) {
|
|
137
|
+
const first = q.options.findIndex((o) => answer.selected!.includes(o.label));
|
|
138
|
+
if (first >= 0) state.cursor = first;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function recordOption(state: QuestionnaireState): void {
|
|
143
|
+
const q = currentQuestion(state);
|
|
144
|
+
if (!q) return;
|
|
145
|
+
const opt = q.options[state.cursor];
|
|
146
|
+
if (!opt) return;
|
|
147
|
+
state.answers[state.currentTab] = {
|
|
148
|
+
questionIndex: state.currentTab,
|
|
149
|
+
question: q.question,
|
|
150
|
+
kind: "option",
|
|
151
|
+
answer: opt.label,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function recordMulti(state: QuestionnaireState): void {
|
|
156
|
+
const q = currentQuestion(state);
|
|
157
|
+
if (!q) return;
|
|
158
|
+
state.answers[state.currentTab] = {
|
|
159
|
+
questionIndex: state.currentTab,
|
|
160
|
+
question: q.question,
|
|
161
|
+
kind: "multi",
|
|
162
|
+
answer: null,
|
|
163
|
+
selected: selectedLabels(state),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function recordCustom(state: QuestionnaireState, text: string): void {
|
|
168
|
+
const q = currentQuestion(state);
|
|
169
|
+
if (!q) return;
|
|
170
|
+
state.answers[state.currentTab] = {
|
|
171
|
+
questionIndex: state.currentTab,
|
|
172
|
+
question: q.question,
|
|
173
|
+
kind: "custom",
|
|
174
|
+
answer: text,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function reduce(state: QuestionnaireState, intent: Intent): Outcome {
|
|
179
|
+
switch (intent.type) {
|
|
180
|
+
case "up": {
|
|
181
|
+
if (!isSubmitTab(state)) state.cursor = Math.max(0, state.cursor - 1);
|
|
182
|
+
return CONTINUE;
|
|
183
|
+
}
|
|
184
|
+
case "down": {
|
|
185
|
+
if (!isSubmitTab(state)) state.cursor = Math.min(rowCount(state) - 1, state.cursor + 1);
|
|
186
|
+
return CONTINUE;
|
|
187
|
+
}
|
|
188
|
+
case "nextTab":
|
|
189
|
+
case "prevTab": {
|
|
190
|
+
if (!hasSubmitTab(state)) return CONTINUE;
|
|
191
|
+
const total = state.questions.length + 1; // 含提交页
|
|
192
|
+
const delta = intent.type === "nextTab" ? 1 : -1;
|
|
193
|
+
const next = (state.currentTab + delta + total) % total;
|
|
194
|
+
focusTab(state, next);
|
|
195
|
+
return CONTINUE;
|
|
196
|
+
}
|
|
197
|
+
case "toggle": {
|
|
198
|
+
const q = currentQuestion(state);
|
|
199
|
+
if (!q || isSubmitTab(state) || !q.multiSelect) return CONTINUE;
|
|
200
|
+
if (state.cursor >= q.options.length) return CONTINUE; // 哨兵行不可勾
|
|
201
|
+
state.toggles[state.currentTab][state.cursor] = !state.toggles[state.currentTab][state.cursor];
|
|
202
|
+
return CONTINUE;
|
|
203
|
+
}
|
|
204
|
+
case "digit": {
|
|
205
|
+
const q = currentQuestion(state);
|
|
206
|
+
if (!q || isSubmitTab(state)) return CONTINUE;
|
|
207
|
+
const d = intent.digit;
|
|
208
|
+
if (d >= 1 && d <= q.options.length) {
|
|
209
|
+
if (q.multiSelect) {
|
|
210
|
+
state.cursor = d - 1;
|
|
211
|
+
state.toggles[state.currentTab][d - 1] = !state.toggles[state.currentTab][d - 1];
|
|
212
|
+
return CONTINUE;
|
|
213
|
+
}
|
|
214
|
+
state.cursor = d - 1;
|
|
215
|
+
recordOption(state);
|
|
216
|
+
return advance(state);
|
|
217
|
+
}
|
|
218
|
+
if (d === q.options.length + 1) {
|
|
219
|
+
state.cursor = q.options.length;
|
|
220
|
+
state.inputMode = true;
|
|
221
|
+
return { status: "enterInput" };
|
|
222
|
+
}
|
|
223
|
+
return CONTINUE;
|
|
224
|
+
}
|
|
225
|
+
case "confirm": {
|
|
226
|
+
if (isSubmitTab(state)) {
|
|
227
|
+
if (!allAnswered(state)) return CONTINUE;
|
|
228
|
+
return { status: "submit", result: { answers: compactAnswers(state), cancelled: false } };
|
|
229
|
+
}
|
|
230
|
+
const q = currentQuestion(state);
|
|
231
|
+
if (!q) return CONTINUE;
|
|
232
|
+
if (state.cursor === q.options.length) {
|
|
233
|
+
state.inputMode = true;
|
|
234
|
+
return { status: "enterInput" };
|
|
235
|
+
}
|
|
236
|
+
if (q.multiSelect) {
|
|
237
|
+
recordMulti(state);
|
|
238
|
+
} else {
|
|
239
|
+
recordOption(state);
|
|
240
|
+
}
|
|
241
|
+
return advance(state);
|
|
242
|
+
}
|
|
243
|
+
case "cancel":
|
|
244
|
+
return { status: "cancel", result: { answers: compactAnswers(state), cancelled: true } };
|
|
245
|
+
case "inputSubmit": {
|
|
246
|
+
const text = intent.text.trim();
|
|
247
|
+
if (text.length === 0) {
|
|
248
|
+
state.inputMode = false;
|
|
249
|
+
return { status: "exitInput" };
|
|
250
|
+
}
|
|
251
|
+
recordCustom(state, text);
|
|
252
|
+
state.inputMode = false;
|
|
253
|
+
return advance(state);
|
|
254
|
+
}
|
|
255
|
+
case "inputCancel":
|
|
256
|
+
state.inputMode = false;
|
|
257
|
+
return { status: "exitInput" };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.ts — ask_user_question 的 TypeBox 参数 schema(只在 pi 运行时加载)。
|
|
3
|
+
*
|
|
4
|
+
* 刻意**不设 maxLength**:pi 在 execute 之前用 schema 校验参数
|
|
5
|
+
* (pi-ai `validateToolArguments`),header/label 超长会让整次调用直接失败、
|
|
6
|
+
* 白烧一个回合;而 chip 标签截断显示毫无危害,所以长度上限交给
|
|
7
|
+
* validate.ts 的运行时归一化(截断 + 补 `…`),schema 的 description 里
|
|
8
|
+
* 仍然写明软上限教模型。数量约束(1-4 题、每题 2-4 项)**保留在 schema 里**:
|
|
9
|
+
* 悄悄丢掉第 5 个选项会歪曲模型本意,让它拿到明确校验错误去重试才对。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
import { CUSTOM_ANSWER_LABEL, MAX_HEADER_LENGTH, MAX_LABEL_LENGTH, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export const AskOptionSchema = Type.Object({
|
|
16
|
+
label: Type.String({
|
|
17
|
+
description: `Display text for this option that the user will see and select. Concise (1-5 words; soft limit ${MAX_LABEL_LENGTH} characters, longer labels are truncated in the UI). Do NOT author "${CUSTOM_ANSWER_LABEL}" or "Other" — reserved.`,
|
|
18
|
+
}),
|
|
19
|
+
description: Type.String({
|
|
20
|
+
description: "Explanation of what this option means or what will happen if chosen. Use it for trade-offs and implications.",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const AskQuestionSchema = Type.Object({
|
|
25
|
+
question: Type.String({
|
|
26
|
+
description: 'The complete question to ask the user. Clear, specific, ends with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
|
|
27
|
+
}),
|
|
28
|
+
header: Type.String({
|
|
29
|
+
description: `Very short chip/tag shown next to the question, max ${MAX_HEADER_LENGTH} characters (longer headers are truncated). Examples: "Auth method", "Library", "Approach".`,
|
|
30
|
+
}),
|
|
31
|
+
options: Type.Array(AskOptionSchema, {
|
|
32
|
+
minItems: MIN_OPTIONS,
|
|
33
|
+
maxItems: MAX_OPTIONS,
|
|
34
|
+
description: `The available choices, ${MIN_OPTIONS}-${MAX_OPTIONS} distinct mutually exclusive options (unless multiSelect). The "${CUSTOM_ANSWER_LABEL}" row is appended automatically — do NOT author it. If you recommend an option, put it first and append "(Recommended)" to its label.`,
|
|
35
|
+
}),
|
|
36
|
+
multiSelect: Type.Optional(
|
|
37
|
+
Type.Boolean({
|
|
38
|
+
description: "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive. Default false.",
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const AskParamsSchema = Type.Object({
|
|
44
|
+
questions: Type.Array(AskQuestionSchema, {
|
|
45
|
+
minItems: 1,
|
|
46
|
+
maxItems: MAX_QUESTIONS,
|
|
47
|
+
description: `Questions to ask the user (1-${MAX_QUESTIONS} per invocation). Group all clarifying questions into one invocation.`,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — ask_user_question 的纯数据模型与常量(单一事实来源)。
|
|
3
|
+
*
|
|
4
|
+
* 刻意不 import typebox / pi / pi-tui:validate.ts / answers.ts / dialog.ts /
|
|
5
|
+
* model.ts 只依赖本文件,所以 `node --test` 能直接跑它们的单测(Node 的类型擦除
|
|
6
|
+
* 只擦标注、不解析运行时依赖)。TypeBox schema 单独放 schema.ts,只在 pi 运行时
|
|
7
|
+
* 被 index.ts 加载。
|
|
8
|
+
*
|
|
9
|
+
* 形状对齐 Claude Code 的 AskUserQuestion(1-4 个问题、每题 2-4 个带说明的选项、
|
|
10
|
+
* 可选 multiSelect),刻意砍掉参考实现(npm:@juicesharp/rpiv-ask-user-question)
|
|
11
|
+
* 里的 preview 侧栏、逐题 note、全局 note 与 i18n —— 自由文本由每题自动追加的
|
|
12
|
+
* "Type something." 哨兵行承担,够用且 UI 简单一个量级。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 一次调用最多问几个问题。 */
|
|
16
|
+
export const MAX_QUESTIONS = 4;
|
|
17
|
+
/** 每题最少选项数。 */
|
|
18
|
+
export const MIN_OPTIONS = 2;
|
|
19
|
+
/** 每题最多选项数(不含自动追加的哨兵行)。 */
|
|
20
|
+
export const MAX_OPTIONS = 4;
|
|
21
|
+
/** 问题 chip 标签的显示上限;超长在运行时截断(不报错)。 */
|
|
22
|
+
export const MAX_HEADER_LENGTH = 16;
|
|
23
|
+
/** 选项 label 的显示上限;超长在运行时截断(不报错)。 */
|
|
24
|
+
export const MAX_LABEL_LENGTH = 60;
|
|
25
|
+
|
|
26
|
+
/** 每题自动追加的自由输入行。模型不许自己写这个 label(reserved_label 拒绝)。 */
|
|
27
|
+
export const CUSTOM_ANSWER_LABEL = "Type something.";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 保留 label:"Other" 是因为模型被 Claude Code 条件化、总想自己写一个 Other 选项,
|
|
31
|
+
* 而这里哨兵行是唯一来源;CUSTOM_ANSWER_LABEL 是哨兵行本身。
|
|
32
|
+
*/
|
|
33
|
+
export const RESERVED_LABELS: readonly string[] = ["Other", CUSTOM_ANSWER_LABEL];
|
|
34
|
+
|
|
35
|
+
export interface AskOption {
|
|
36
|
+
label: string;
|
|
37
|
+
description: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AskQuestion {
|
|
41
|
+
question: string;
|
|
42
|
+
header: string;
|
|
43
|
+
options: AskOption[];
|
|
44
|
+
multiSelect?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AskParams {
|
|
48
|
+
questions: AskQuestion[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 单题答案意图(判别联合,`kind` 是唯一判别子):
|
|
53
|
+
* - `option`:单选,选中模型给的选项;`answer` = 选项 label
|
|
54
|
+
* - `custom`:用户在 "Type something." 行自由输入;`answer` = 输入文本
|
|
55
|
+
* - `multi`:多选提交;`selected` = 选中的 label 列表,`answer` 恒为 null
|
|
56
|
+
*/
|
|
57
|
+
export type AskAnswerKind = "option" | "custom" | "multi";
|
|
58
|
+
|
|
59
|
+
export interface AskAnswer {
|
|
60
|
+
questionIndex: number;
|
|
61
|
+
question: string;
|
|
62
|
+
kind: AskAnswerKind;
|
|
63
|
+
answer: string | null;
|
|
64
|
+
selected?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 失败码。`no_ui` / `no_dialog_ui` 依赖宿主能力、在执行路径上判定;
|
|
69
|
+
* 其余由 validateQuestionnaire 产出。
|
|
70
|
+
*/
|
|
71
|
+
export type AskErrorCode =
|
|
72
|
+
| "no_ui"
|
|
73
|
+
| "no_dialog_ui"
|
|
74
|
+
| "no_questions"
|
|
75
|
+
| "too_many_questions"
|
|
76
|
+
| "duplicate_question"
|
|
77
|
+
| "too_few_options"
|
|
78
|
+
| "too_many_options"
|
|
79
|
+
| "reserved_label"
|
|
80
|
+
| "duplicate_option_label";
|
|
81
|
+
|
|
82
|
+
export interface AskResult {
|
|
83
|
+
answers: AskAnswer[];
|
|
84
|
+
cancelled: boolean;
|
|
85
|
+
error?: AskErrorCode;
|
|
86
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for validate.ts — 归一化(行终止符 / 单行化 / 截断)+ 运行时校验。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/ask-user-question/validate.test.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import {
|
|
10
|
+
ERROR_DUPLICATE_OPTION_LABEL,
|
|
11
|
+
ERROR_DUPLICATE_QUESTION,
|
|
12
|
+
ERROR_NO_QUESTIONS,
|
|
13
|
+
ERROR_RESERVED_LABEL,
|
|
14
|
+
ERROR_TOO_FEW_OPTIONS,
|
|
15
|
+
ERROR_TOO_MANY_OPTIONS,
|
|
16
|
+
ERROR_TOO_MANY_QUESTIONS,
|
|
17
|
+
normalizeLineTerminators,
|
|
18
|
+
normalizeParams,
|
|
19
|
+
validateQuestionnaire,
|
|
20
|
+
} from "./validate.ts";
|
|
21
|
+
import { MAX_HEADER_LENGTH, MAX_LABEL_LENGTH, type AskParams } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
function option(label: string, description = "desc") {
|
|
24
|
+
return { label, description };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function params(overrides: Partial<AskParams> = {}): AskParams {
|
|
28
|
+
return {
|
|
29
|
+
questions: [
|
|
30
|
+
{
|
|
31
|
+
question: "Which library should we use?",
|
|
32
|
+
header: "Library",
|
|
33
|
+
options: [option("date-fns"), option("dayjs")],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("normalizeLineTerminators: CRLF 折成 LF,落单 CR 删除", () => {
|
|
41
|
+
assert.equal(normalizeLineTerminators("a\r\nb"), "a\nb");
|
|
42
|
+
assert.equal(normalizeLineTerminators("GEMBA\r_LOG\r_FILE"), "GEMBA_LOG_FILE");
|
|
43
|
+
assert.equal(normalizeLineTerminators("a\nb"), "a\nb");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("normalizeParams: header 超长截断补 …", () => {
|
|
47
|
+
const long = "x".repeat(MAX_HEADER_LENGTH + 10);
|
|
48
|
+
const out = normalizeParams(params({ questions: [{ ...params().questions[0], header: long }] }));
|
|
49
|
+
const header = out.questions[0].header;
|
|
50
|
+
assert.equal(header.length, MAX_HEADER_LENGTH);
|
|
51
|
+
assert.ok(header.endsWith("…"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("normalizeParams: label 超长截断补 …", () => {
|
|
55
|
+
const long = "y".repeat(MAX_LABEL_LENGTH + 5);
|
|
56
|
+
const out = normalizeParams(
|
|
57
|
+
params({ questions: [{ ...params().questions[0], options: [option(long), option("b")] }] }),
|
|
58
|
+
);
|
|
59
|
+
assert.equal(out.questions[0].options[0].label.length, MAX_LABEL_LENGTH);
|
|
60
|
+
assert.ok(out.questions[0].options[0].label.endsWith("…"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("normalizeParams: header/label/description 单行化(空白串折叠),question 保留换行", () => {
|
|
64
|
+
const out = normalizeParams(
|
|
65
|
+
params({
|
|
66
|
+
questions: [
|
|
67
|
+
{
|
|
68
|
+
question: "line one\r\nline two",
|
|
69
|
+
header: " Auth\r\nmethod ",
|
|
70
|
+
options: [{ label: " A\r\nB ", description: "d1\n\n d2 " }],
|
|
71
|
+
},
|
|
72
|
+
{ ...params().questions[0], question: "other" },
|
|
73
|
+
],
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
assert.equal(out.questions[0].question, "line one\nline two");
|
|
77
|
+
assert.equal(out.questions[0].header, "Auth method");
|
|
78
|
+
assert.equal(out.questions[0].options[0].label, "A B");
|
|
79
|
+
assert.equal(out.questions[0].options[0].description, "d1 d2");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("normalizeParams: multiSelect 归一成布尔,不改入参", () => {
|
|
83
|
+
const input = params({ questions: [{ ...params().questions[0], multiSelect: undefined }] });
|
|
84
|
+
const out = normalizeParams(input);
|
|
85
|
+
assert.equal(out.questions[0].multiSelect, false);
|
|
86
|
+
assert.equal(input.questions[0].multiSelect, undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("validateQuestionnaire: 合法问卷通过", () => {
|
|
90
|
+
assert.deepEqual(validateQuestionnaire(normalizeParams(params())), { ok: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("validateQuestionnaire: 空 questions → no_questions", () => {
|
|
94
|
+
const result = validateQuestionnaire({ questions: [] });
|
|
95
|
+
assert.equal(result.ok, false);
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
assert.equal(result.error, "no_questions");
|
|
98
|
+
assert.equal(result.message, ERROR_NO_QUESTIONS);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("validateQuestionnaire: 超过 4 题 → too_many_questions", () => {
|
|
103
|
+
const q = params().questions[0];
|
|
104
|
+
const five = {
|
|
105
|
+
questions: [1, 2, 3, 4, 5].map((i) => ({ ...q, question: `Q${i}?`, header: `H${i}` })),
|
|
106
|
+
};
|
|
107
|
+
const result = validateQuestionnaire(five);
|
|
108
|
+
assert.equal(result.ok, false);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
assert.equal(result.error, "too_many_questions");
|
|
111
|
+
assert.equal(result.message, ERROR_TOO_MANY_QUESTIONS);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("validateQuestionnaire: 重复问题文本 → duplicate_question", () => {
|
|
116
|
+
const q = params().questions[0];
|
|
117
|
+
const result = validateQuestionnaire({ questions: [q, { ...q, header: "Again" }] });
|
|
118
|
+
assert.equal(result.ok, false);
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
assert.equal(result.error, "duplicate_question");
|
|
121
|
+
assert.equal(result.message, ERROR_DUPLICATE_QUESTION);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("validateQuestionnaire: 选项太少 / 太多", () => {
|
|
126
|
+
const q = params().questions[0];
|
|
127
|
+
const tooFew = validateQuestionnaire({ questions: [{ ...q, options: [option("only")] }] });
|
|
128
|
+
assert.equal(tooFew.ok, false);
|
|
129
|
+
if (!tooFew.ok) {
|
|
130
|
+
assert.equal(tooFew.error, "too_few_options");
|
|
131
|
+
assert.equal(tooFew.message, ERROR_TOO_FEW_OPTIONS);
|
|
132
|
+
}
|
|
133
|
+
const tooMany = validateQuestionnaire({
|
|
134
|
+
questions: [{ ...q, options: [option("a"), option("b"), option("c"), option("d"), option("e")] }],
|
|
135
|
+
});
|
|
136
|
+
assert.equal(tooMany.ok, false);
|
|
137
|
+
if (!tooMany.ok) {
|
|
138
|
+
assert.equal(tooMany.error, "too_many_options");
|
|
139
|
+
assert.equal(tooMany.message, ERROR_TOO_MANY_OPTIONS);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("validateQuestionnaire: 保留 label(Other / Type something.)被拒", () => {
|
|
144
|
+
for (const label of ["Other", "Type something."]) {
|
|
145
|
+
const result = validateQuestionnaire({
|
|
146
|
+
questions: [{ ...params().questions[0], options: [option(label), option("b")] }],
|
|
147
|
+
});
|
|
148
|
+
assert.equal(result.ok, false, `label ${label} should be rejected`);
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
assert.equal(result.error, "reserved_label");
|
|
151
|
+
assert.equal(result.message, ERROR_RESERVED_LABEL);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("validateQuestionnaire: 同题重复 label → duplicate_option_label", () => {
|
|
157
|
+
const result = validateQuestionnaire({
|
|
158
|
+
questions: [{ ...params().questions[0], options: [option("same"), option("same")] }],
|
|
159
|
+
});
|
|
160
|
+
assert.equal(result.ok, false);
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
assert.equal(result.error, "duplicate_option_label");
|
|
163
|
+
assert.equal(result.message, ERROR_DUPLICATE_OPTION_LABEL);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("reserved_label 短路在 duplicate_option_label 之前(两个 Other)", () => {
|
|
168
|
+
const result = validateQuestionnaire({
|
|
169
|
+
questions: [{ ...params().questions[0], options: [option("Other"), option("Other")] }],
|
|
170
|
+
});
|
|
171
|
+
assert.equal(result.ok, false);
|
|
172
|
+
if (!result.ok) assert.equal(result.error, "reserved_label");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("归一化后再校验:\"Other\\r\" 不能溜过 reserved_label", () => {
|
|
176
|
+
const normalized = normalizeParams({
|
|
177
|
+
questions: [{ ...params().questions[0], options: [option("Other\r"), option("b")] }],
|
|
178
|
+
});
|
|
179
|
+
assert.equal(normalized.questions[0].options[0].label, "Other");
|
|
180
|
+
const result = validateQuestionnaire(normalized);
|
|
181
|
+
assert.equal(result.ok, false);
|
|
182
|
+
if (!result.ok) assert.equal(result.error, "reserved_label");
|
|
183
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate.ts — 参数归一化 + 运行时校验(纯函数,不 import pi / pi-tui / typebox)。
|
|
3
|
+
*
|
|
4
|
+
* 归一化在 execute 入口跑一次、校验之前,所以下游所有消费者(校验器、TUI、
|
|
5
|
+
* 对话框回退、答案封装)看到的都是同一份干净文本:
|
|
6
|
+
* - 行终止符:`\r\n` → `\n`、落单 `\r` 删除(CR 是光标控制字节不是文本,
|
|
7
|
+
* 直接进渲染会把行首指针冲掉;与 pi 自己的显示归一化口径一致)
|
|
8
|
+
* - header / label / description 是单行 chip / 列表行,空白串(含换行)折叠成
|
|
9
|
+
* 一个空格再截断;question 保留换行(渲染端逐行折行)
|
|
10
|
+
* - 截断补 `…`,上限见 types.ts(刻意不在 schema 里设 maxLength,理由见 schema.ts)
|
|
11
|
+
*
|
|
12
|
+
* 校验覆盖数量与重复/保留 label;`reserved_label` 必须短路在
|
|
13
|
+
* `duplicate_option_label` 之前(两个 "Other" 首先是保留字问题)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
MAX_HEADER_LENGTH,
|
|
18
|
+
MAX_LABEL_LENGTH,
|
|
19
|
+
MAX_OPTIONS,
|
|
20
|
+
MAX_QUESTIONS,
|
|
21
|
+
MIN_OPTIONS,
|
|
22
|
+
RESERVED_LABELS,
|
|
23
|
+
type AskErrorCode,
|
|
24
|
+
type AskParams,
|
|
25
|
+
} from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export const ERROR_NO_QUESTIONS = "Error: At least one question is required";
|
|
28
|
+
export const ERROR_TOO_MANY_QUESTIONS = `Error: At most ${MAX_QUESTIONS} questions are allowed per invocation — group them into one call with fewer questions`;
|
|
29
|
+
export const ERROR_DUPLICATE_QUESTION = "Error: Question text must be unique within an invocation";
|
|
30
|
+
export const ERROR_TOO_FEW_OPTIONS = `Error: Each question requires at least ${MIN_OPTIONS} options`;
|
|
31
|
+
export const ERROR_TOO_MANY_OPTIONS = `Error: Each question allows at most ${MAX_OPTIONS} options`;
|
|
32
|
+
export const ERROR_RESERVED_LABEL = `Error: Option label is reserved (${RESERVED_LABELS.join(", ")}) — the custom-answer row is appended automatically`;
|
|
33
|
+
export const ERROR_DUPLICATE_OPTION_LABEL = "Error: Option labels must be unique within a question";
|
|
34
|
+
|
|
35
|
+
const RESERVED_LABEL_SET: ReadonlySet<string> = new Set(RESERVED_LABELS);
|
|
36
|
+
|
|
37
|
+
export type ValidationResult = { ok: true } | { ok: false; error: AskErrorCode; message: string };
|
|
38
|
+
|
|
39
|
+
/** `\r\n` → `\n`,落单 `\r` 删除(不是空格:会在词中间留幻影间隙)。 */
|
|
40
|
+
export function normalizeLineTerminators(text: string): string {
|
|
41
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 单行化:行终止符归一 + 空白串折叠成一个空格 + 去首尾。 */
|
|
45
|
+
function toSingleLine(text: string): string {
|
|
46
|
+
return normalizeLineTerminators(text).replace(/\s+/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 按**字符数**截断到 max,超长补 `…`(max ≥ 2 才有意义)。 */
|
|
50
|
+
function clip(text: string, max: number): string {
|
|
51
|
+
if (text.length <= max) return text;
|
|
52
|
+
return `${text.slice(0, max - 1)}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 返回参数副本:所有展示字符串归一化,header/label 截断。纯函数,不改入参。
|
|
57
|
+
* 保留 label 的**比较**发生在归一化之后(`"Other\r"` 不能溜过 reserved_label)。
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeParams(params: AskParams): AskParams {
|
|
60
|
+
return {
|
|
61
|
+
questions: params.questions.map((q) => ({
|
|
62
|
+
question: normalizeLineTerminators(q.question).trim(),
|
|
63
|
+
header: clip(toSingleLine(q.header), MAX_HEADER_LENGTH),
|
|
64
|
+
multiSelect: q.multiSelect === true,
|
|
65
|
+
options: q.options.map((o) => ({
|
|
66
|
+
label: clip(toSingleLine(o.label), MAX_LABEL_LENGTH),
|
|
67
|
+
description: toSingleLine(o.description),
|
|
68
|
+
})),
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 纯运行时校验器。no_ui / no_dialog_ui 依赖宿主能力,留在执行路径上判。 */
|
|
74
|
+
export function validateQuestionnaire(params: AskParams): ValidationResult {
|
|
75
|
+
if (params.questions.length === 0) {
|
|
76
|
+
return { ok: false, error: "no_questions", message: ERROR_NO_QUESTIONS };
|
|
77
|
+
}
|
|
78
|
+
if (params.questions.length > MAX_QUESTIONS) {
|
|
79
|
+
return { ok: false, error: "too_many_questions", message: ERROR_TOO_MANY_QUESTIONS };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const seenQuestions = new Set<string>();
|
|
83
|
+
for (const q of params.questions) {
|
|
84
|
+
if (seenQuestions.has(q.question)) {
|
|
85
|
+
return { ok: false, error: "duplicate_question", message: ERROR_DUPLICATE_QUESTION };
|
|
86
|
+
}
|
|
87
|
+
seenQuestions.add(q.question);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const q of params.questions) {
|
|
91
|
+
if (q.options.length < MIN_OPTIONS) {
|
|
92
|
+
return { ok: false, error: "too_few_options", message: ERROR_TOO_FEW_OPTIONS };
|
|
93
|
+
}
|
|
94
|
+
if (q.options.length > MAX_OPTIONS) {
|
|
95
|
+
return { ok: false, error: "too_many_options", message: ERROR_TOO_MANY_OPTIONS };
|
|
96
|
+
}
|
|
97
|
+
const seenLabels = new Set<string>();
|
|
98
|
+
for (const o of q.options) {
|
|
99
|
+
if (RESERVED_LABEL_SET.has(o.label)) {
|
|
100
|
+
return { ok: false, error: "reserved_label", message: ERROR_RESERVED_LABEL };
|
|
101
|
+
}
|
|
102
|
+
if (seenLabels.has(o.label)) {
|
|
103
|
+
return { ok: false, error: "duplicate_option_label", message: ERROR_DUPLICATE_OPTION_LABEL };
|
|
104
|
+
}
|
|
105
|
+
seenLabels.add(o.label);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|