@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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask-user-question — 给 pi 一个 Claude Code `AskUserQuestion` 式的提问工具。
|
|
3
|
+
*
|
|
4
|
+
* 模型在需求不明确时不再自己猜,而是调 `ask_user_question`:终端里弹出一份
|
|
5
|
+
* 最多 4 题的问卷(每题 2-4 个带说明的选项),用户 ↑↓ 选、Space 勾多选、
|
|
6
|
+
* 落到自动追加的 "Type something." 行可以自己打字,Esc 整份放弃;答案以
|
|
7
|
+
* 结构化文本回到模型上下文。
|
|
8
|
+
*
|
|
9
|
+
* 参考 `npm:@juicesharp/rpiv-ask-user-question` 的**契约**(参数形状、保留
|
|
10
|
+
* label、取消语义、非交互宿主把工具从 active 列表摘掉、RPC 宿主回退到
|
|
11
|
+
* select/input 对话框),但自己写实现并砍掉它的 preview 侧栏、逐题/全局
|
|
12
|
+
* note、i18n、overlay 折叠与 ~560ms 的懒加载渲染图 —— 本扩展用
|
|
13
|
+
* `ctx.ui.custom()` 的普通对象组件(examples/extensions/question.ts 同款),
|
|
14
|
+
* 零 npm 依赖、七个文件、可单测的逻辑全在纯模块里。
|
|
15
|
+
*
|
|
16
|
+
* 模块划分(`types/validate/answers/dialog/model` 刻意不 import pi / pi-tui,
|
|
17
|
+
* 所以 `node --test clients/pi/extensions/ask-user-question/*.test.ts` 能直接跑;
|
|
18
|
+
* `schema.ts` 要 typebox、`view.ts` 要 pi-tui,只在 pi 运行时加载):
|
|
19
|
+
*
|
|
20
|
+
* types.ts 常量 + 数据模型(单一事实来源)
|
|
21
|
+
* schema.ts TypeBox 参数 schema(数量约束在 schema,长度上限在运行时截断)
|
|
22
|
+
* validate.ts 行终止符归一 / 单行化 / 截断 + 数量与保留 label 校验
|
|
23
|
+
* answers.ts 结果 → 给模型的 tool result 封装(含取消与错误路径)
|
|
24
|
+
* dialog.ts 非 TUI 宿主的顺序 select/input 回退
|
|
25
|
+
* model.ts 问卷状态机(纯 reducer:tab / 光标 / 勾选 / 提交 / 取消)
|
|
26
|
+
* view.ts TUI 外壳(按键解码 + 渲染),index.ts 注册工具/命令/reconcile
|
|
27
|
+
*
|
|
28
|
+
* 放在 `~/.pi/agent/extensions/ask-user-question/`(pi 自动发现子目录里的
|
|
29
|
+
* index.ts),支持 `/reload` 热重载。`PI_ASK_USER_QUESTION=off` 整体不装。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
34
|
+
import { formatAnswerScalar, buildResponse, errorResult } from "./answers.ts";
|
|
35
|
+
import { hasDialogUI, runDialogQuestionnaire } from "./dialog.ts";
|
|
36
|
+
import { normalizeParams, validateQuestionnaire } from "./validate.ts";
|
|
37
|
+
import { createQuestionnaireView } from "./view.ts";
|
|
38
|
+
import { AskParamsSchema } from "./schema.ts";
|
|
39
|
+
import {
|
|
40
|
+
CUSTOM_ANSWER_LABEL,
|
|
41
|
+
MAX_OPTIONS,
|
|
42
|
+
MAX_QUESTIONS,
|
|
43
|
+
MIN_OPTIONS,
|
|
44
|
+
type AskParams,
|
|
45
|
+
type AskResult,
|
|
46
|
+
} from "./types.ts";
|
|
47
|
+
|
|
48
|
+
/** 工具名(也是 reconcile 与 /ask 共用的唯一标识)。 */
|
|
49
|
+
export const TOOL_NAME = "ask_user_question";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 问卷阻塞期间 spinner 冻结在这一帧。选盲文全集字符:与默认动画帧
|
|
53
|
+
* (⠋⠙…)同族,看起来是「spinner 停住了」而不是换了个东西。
|
|
54
|
+
*/
|
|
55
|
+
const FROZEN_SPINNER_FRAME = "⠿";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 两条宿主能力错误的正文都是**写给模型**的:必须说清"用户根本没看到问题",
|
|
59
|
+
* 否则模型会把静默失败读成用户拒绝回答,然后自顾自继续猜。
|
|
60
|
+
*/
|
|
61
|
+
const ERROR_NO_UI =
|
|
62
|
+
"Error: UI not available (running in non-interactive mode) — the user never saw the questions. Ask them as plain chat text instead, without using this tool.";
|
|
63
|
+
const ERROR_NO_DIALOG_UI =
|
|
64
|
+
"Error: this client can render neither the questionnaire nor select/input dialogs — the user never saw the questions. Do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
|
|
65
|
+
|
|
66
|
+
const DESCRIPTION = `Ask the user one or more structured questions and wait for the answers. Use when you need to:
|
|
67
|
+
1. Gather user preferences or requirements
|
|
68
|
+
2. Clarify ambiguous instructions
|
|
69
|
+
3. Get decisions on implementation choices as you work
|
|
70
|
+
4. Offer choices to the user about what direction to take
|
|
71
|
+
|
|
72
|
+
Usage notes:
|
|
73
|
+
- Every question gets an automatically appended "${CUSTOM_ANSWER_LABEL}" row, so the user can always answer in their own words. Esc abandons the whole questionnaire and you receive "${"User declined to answer the questions"}" — that is a decline to answer, not an answer.
|
|
74
|
+
- Each question needs ${MIN_OPTIONS}-${MAX_OPTIONS} options, each with a concise label (1-5 words) and a description of what the choice means or costs.
|
|
75
|
+
- Set multiSelect: true when the choices are not mutually exclusive.
|
|
76
|
+
- If you recommend an option, put it first and append "(Recommended)" to its label.
|
|
77
|
+
- Do NOT author "${CUSTOM_ANSWER_LABEL}" or "Other" option labels — reserved labels are rejected at runtime.
|
|
78
|
+
- Group every clarifying question into ONE invocation (up to ${MAX_QUESTIONS}) instead of asking one at a time.`;
|
|
79
|
+
|
|
80
|
+
const PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 只写「怎么用」,不写「多主动」:提问阈值(什么算值得打断用户)由 AGENTS.md 的 `## Authorization`
|
|
84
|
+
* 一节定义,本文件不再声明。2026-09-18 之前这里有一条「you cannot proceed」——它比本文件 DESCRIPTION
|
|
85
|
+
* 里的「Clarify ambiguous instructions」更窄,同文件两处矛盾,而窄的那条实际生效。
|
|
86
|
+
*/
|
|
87
|
+
const PROMPT_GUIDELINES: string[] = [
|
|
88
|
+
`Every ask_user_question option needs a concise label (1-5 words) plus a description of what the choice means or costs. Do NOT author "${CUSTOM_ANSWER_LABEL}" or "Other" labels — a free-text row is appended automatically and reserved labels are rejected.`,
|
|
89
|
+
"Set multiSelect: true in ask_user_question when the choices are not mutually exclusive; put a recommended option first with \"(Recommended)\" appended to its label.",
|
|
90
|
+
"Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 真正跑一次问卷。TUI 走 custom 组件;其余模式(RPC / ACP 宿主)走 select/input
|
|
95
|
+
* 顺序对话框;两者都不可用时返回 no_dialog_ui。
|
|
96
|
+
*
|
|
97
|
+
* `ctx.ui.custom()` 解析成 `undefined` 只可能是"宿主渲染不了自定义 UI"
|
|
98
|
+
* (TUI 问卷一定会 resolve 一个 AskResult,取消也算),所以那不是用户拒绝,
|
|
99
|
+
* 继续尝试对话框回退。
|
|
100
|
+
*
|
|
101
|
+
* TUI 路径在等待期间**冻结 working spinner 动画**:此刻在等用户作答而不是
|
|
102
|
+
* 模型干活,动画继续转会让人以为还在推理。pi-tui Loader 对单帧 indicator
|
|
103
|
+
* 不动画(`restartAnimation` 遇 `frames.length <= 1` 直接返回),且自定义帧
|
|
104
|
+
* 是 verbatim 渲染(不走 spinnerColorFn),所以颜色自己带上 accent(与默认
|
|
105
|
+
* spinner 同色)。提交/取消后无参 `setWorkingIndicator()` 恢复默认十帧动画。
|
|
106
|
+
* 冻结不怕被每秒刷新的 working 文案顶掉:`setWorkingMessage` 只改文本不重建
|
|
107
|
+
* indicator;即使重建,interactive-mode 也会带上存好的 workingIndicatorOptions。
|
|
108
|
+
* try/finally 保证作答、取消、宿主回退每条路径都恢复;问卷开着时组件持有
|
|
109
|
+
* 输入焦点、用户无法开启新回合,所以冻结帧不会泄漏到下一回合。
|
|
110
|
+
* 本机的 `working-indicator/` 扩展**会**用 `setWorkingIndicator` 装自定义帧
|
|
111
|
+
* (spinner 幻彩:十帧盲文轮换主题色,见那份 README),但它的帧表只在
|
|
112
|
+
* `agent_start` / 主题指纹变化 / `ask_user_question` 的 `tool_execution_end`
|
|
113
|
+
* 这三个时机装 —— 也就是问卷一结束就补装回去,所以这里的无参恢复(回到 pi
|
|
114
|
+
* 默认十帧)只会短暂盖住它,不需要两边共享任何状态。
|
|
115
|
+
*/
|
|
116
|
+
async function runQuestionnaire(ctx: ExtensionContext, params: AskParams) {
|
|
117
|
+
if (ctx.mode !== "tui") {
|
|
118
|
+
if (hasDialogUI(ctx.ui)) return buildResponse(await runDialogQuestionnaire(ctx.ui, params), params);
|
|
119
|
+
return errorResult(ERROR_NO_DIALOG_UI, "no_dialog_ui");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", FROZEN_SPINNER_FRAME)] });
|
|
123
|
+
try {
|
|
124
|
+
const result = await ctx.ui.custom<AskResult | undefined>((tui, theme, _keybindings, done) =>
|
|
125
|
+
createQuestionnaireView(tui, theme, params, done),
|
|
126
|
+
);
|
|
127
|
+
if (result === undefined) {
|
|
128
|
+
if (hasDialogUI(ctx.ui)) return buildResponse(await runDialogQuestionnaire(ctx.ui, params), params);
|
|
129
|
+
return errorResult(ERROR_NO_DIALOG_UI, "no_dialog_ui");
|
|
130
|
+
}
|
|
131
|
+
return buildResponse(result, params);
|
|
132
|
+
} finally {
|
|
133
|
+
ctx.ui.setWorkingIndicator();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 非交互运行(`pi -p` / JSON 模式)里把工具从 active 列表摘掉,模型根本看不到它,
|
|
139
|
+
* 好过每次调用都失败;恢复交互(同一进程里换会话等)再加回来。幂等:状态已正确
|
|
140
|
+
* 就不碰 active 列表(也不碰别的扩展注册的工具)。
|
|
141
|
+
*/
|
|
142
|
+
function reconcileTool(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
143
|
+
const active = pi.getActiveTools();
|
|
144
|
+
const present = active.includes(TOOL_NAME);
|
|
145
|
+
if (!ctx.hasUI && present) {
|
|
146
|
+
pi.setActiveTools(active.filter((name) => name !== TOOL_NAME));
|
|
147
|
+
} else if (ctx.hasUI && !present) {
|
|
148
|
+
pi.setActiveTools([...active, TOOL_NAME]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** /ask 的演示问卷:一题中文单选(验 CJK 折行)+ 一题英文多选(验勾选与提交页)。 */
|
|
153
|
+
function demoParams(): AskParams {
|
|
154
|
+
return {
|
|
155
|
+
questions: [
|
|
156
|
+
{
|
|
157
|
+
question: "这份问卷的交互形态,你更希望是哪一种?",
|
|
158
|
+
header: "交互形态",
|
|
159
|
+
options: [
|
|
160
|
+
{ label: "覆盖编辑器区 (Recommended)", description: "ctx.ui.custom() 非 overlay:问卷接管输入框位置,实现最简、渲染最稳" },
|
|
161
|
+
{ label: "浮层 overlay", description: "浮在对话上方,答题时仍能看见 transcript,但要处理折叠/焦点" },
|
|
162
|
+
{ label: "顺序对话框", description: "逐题走宿主原生 select/input,没有总览页,RPC 宿主就是这条路径" },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
question: "Which extra affordances should the questionnaire support?",
|
|
167
|
+
header: "Features",
|
|
168
|
+
multiSelect: true,
|
|
169
|
+
options: [
|
|
170
|
+
{ label: "Option previews", description: "Markdown preview pane rendered beside the option list" },
|
|
171
|
+
{ label: "Per-question notes", description: "Attach a free-text note to any single answer" },
|
|
172
|
+
{ label: "Collapse overlay", description: "Hide the dialog with a key so you can scroll the transcript" },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default function (pi: ExtensionAPI) {
|
|
180
|
+
if (process.env.PI_ASK_USER_QUESTION === "off") return;
|
|
181
|
+
|
|
182
|
+
pi.registerTool({
|
|
183
|
+
name: TOOL_NAME,
|
|
184
|
+
label: "Ask User Question",
|
|
185
|
+
description: DESCRIPTION,
|
|
186
|
+
promptSnippet: PROMPT_SNIPPET,
|
|
187
|
+
promptGuidelines: PROMPT_GUIDELINES,
|
|
188
|
+
parameters: AskParamsSchema,
|
|
189
|
+
// 阻塞式 UI:不能与别的工具调用并行执行,否则同一回合里两个弹窗抢输入焦点。
|
|
190
|
+
executionMode: "sequential",
|
|
191
|
+
|
|
192
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
193
|
+
// 归一化跑在校验之前:保留 label 与重复 label 的比较必须用用户真正看到的文本。
|
|
194
|
+
const typed = normalizeParams(params as unknown as AskParams);
|
|
195
|
+
if (!ctx.hasUI) return errorResult(ERROR_NO_UI, "no_ui");
|
|
196
|
+
const validation = validateQuestionnaire(typed);
|
|
197
|
+
if (!validation.ok) return errorResult(validation.message, validation.error);
|
|
198
|
+
return runQuestionnaire(ctx, typed);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
renderCall(args, theme) {
|
|
202
|
+
const questions = (args as Partial<AskParams>)?.questions;
|
|
203
|
+
const qs = Array.isArray(questions) ? questions : [];
|
|
204
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user_question "));
|
|
205
|
+
text += theme.fg("muted", qs.length === 1 ? (qs[0]?.question ?? "") : `${qs.length} questions`);
|
|
206
|
+
for (const q of qs) {
|
|
207
|
+
const options = Array.isArray(q?.options) ? q.options : [];
|
|
208
|
+
const labels = options.map((o, i) => `${i + 1}. ${o?.label ?? ""}`);
|
|
209
|
+
labels.push(`${labels.length + 1}. ${CUSTOM_ANSWER_LABEL}`);
|
|
210
|
+
const head = qs.length === 1 ? "" : `[${q?.header ?? ""}] `;
|
|
211
|
+
const multi = q?.multiSelect ? " (multi)" : "";
|
|
212
|
+
text += `\n${theme.fg("dim", ` ${head}${labels.join(" · ")}${multi}`)}`;
|
|
213
|
+
}
|
|
214
|
+
return new Text(text, 0, 0);
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
renderResult(result, _options, theme, context) {
|
|
218
|
+
const details = result.details as AskResult | undefined;
|
|
219
|
+
if (!details) {
|
|
220
|
+
const first = result.content[0];
|
|
221
|
+
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
|
222
|
+
}
|
|
223
|
+
if (details.error) return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
224
|
+
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
225
|
+
|
|
226
|
+
const questions = (context.args as Partial<AskParams>)?.questions;
|
|
227
|
+
const qs = Array.isArray(questions) ? questions : [];
|
|
228
|
+
const lines = details.answers.map((a) => {
|
|
229
|
+
const header = qs[a.questionIndex]?.header || `Q${a.questionIndex + 1}`;
|
|
230
|
+
const answer = a.kind === "custom" ? `✎ ${a.answer ?? ""}` : formatAnswerScalar(a);
|
|
231
|
+
return `${theme.fg("success", "✓")} ${theme.fg("muted", `${header}: `)}${theme.fg("text", answer)}`;
|
|
232
|
+
});
|
|
233
|
+
return new Text(lines.join("\n") || theme.fg("dim", "(no answers)"), 0, 0);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.on("before_agent_start", (_event, ctx) => reconcileTool(pi, ctx));
|
|
238
|
+
|
|
239
|
+
// 不花 token 也能验 UI:/ask 弹一份演示问卷(中文单选 + 英文多选),
|
|
240
|
+
// 走的是与工具完全相同的 runQuestionnaire 路径。
|
|
241
|
+
pi.registerCommand("ask", {
|
|
242
|
+
description: "Preview the ask_user_question dialog with a demo questionnaire",
|
|
243
|
+
handler: async (_args, ctx) => {
|
|
244
|
+
if (!ctx.hasUI) {
|
|
245
|
+
ctx.ui.notify("ask: no UI available in this mode", "warning");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const result = await runQuestionnaire(ctx, demoParams());
|
|
249
|
+
const text = result.content[0]?.text ?? "";
|
|
250
|
+
ctx.ui.notify(text.length > 240 ? `${text.slice(0, 240)}…` : text, "info");
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for model.ts — 问卷状态机(纯 reducer)。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/ask-user-question/model.test.ts
|
|
5
|
+
*
|
|
6
|
+
* 覆盖:单选提交即结束(单题无提交页)、数字键、多选勾选/提交、自由输入
|
|
7
|
+
* (含空输入退回)、取消、多题推进与提交页闸门、Tab 环绕切换、重答恢复光标、
|
|
8
|
+
* 哨兵行与边界(上下键 clamp、单选题 Space 无效、提交页数字键无效)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import {
|
|
14
|
+
allAnswered,
|
|
15
|
+
createState,
|
|
16
|
+
hasSubmitTab,
|
|
17
|
+
isSubmitTab,
|
|
18
|
+
reduce,
|
|
19
|
+
rowCount,
|
|
20
|
+
unansweredHeaders,
|
|
21
|
+
type Outcome,
|
|
22
|
+
} from "./model.ts";
|
|
23
|
+
import type { AskParams } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
function option(label: string) {
|
|
26
|
+
return { label, description: `${label} desc` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function singleParams(): AskParams {
|
|
30
|
+
return {
|
|
31
|
+
questions: [
|
|
32
|
+
{ question: "Which library?", header: "Lib", options: [option("date-fns"), option("dayjs"), option("luxon")] },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function multiSelectParams(): AskParams {
|
|
38
|
+
return {
|
|
39
|
+
questions: [
|
|
40
|
+
{
|
|
41
|
+
question: "Which features?",
|
|
42
|
+
header: "Feat",
|
|
43
|
+
multiSelect: true,
|
|
44
|
+
options: [option("X"), option("Y"), option("Z")],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function twoParams(): AskParams {
|
|
51
|
+
return {
|
|
52
|
+
questions: [
|
|
53
|
+
{ question: "Q1?", header: "One", options: [option("a"), option("b")] },
|
|
54
|
+
{ question: "Q2?", header: "Two", options: [option("c"), option("d")] },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function submitted(outcome: Outcome) {
|
|
60
|
+
assert.equal(outcome.status, "submit");
|
|
61
|
+
if (outcome.status !== "submit") throw new Error("unreachable");
|
|
62
|
+
return outcome.result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("单题没有提交页;行数 = 选项数 + 1(哨兵)", () => {
|
|
66
|
+
const state = createState(singleParams());
|
|
67
|
+
assert.equal(hasSubmitTab(state), false);
|
|
68
|
+
assert.equal(isSubmitTab(state), false);
|
|
69
|
+
assert.equal(rowCount(state), 4);
|
|
70
|
+
assert.equal(allAnswered(state), false);
|
|
71
|
+
assert.deepEqual(unansweredHeaders(state), ["Lib"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("单选:光标移到选项上 Enter → 直接提交(单题无提交页)", () => {
|
|
75
|
+
const state = createState(singleParams());
|
|
76
|
+
reduce(state, { type: "down" }); // cursor = 1 → dayjs
|
|
77
|
+
const result = submitted(reduce(state, { type: "confirm" }));
|
|
78
|
+
assert.equal(result.cancelled, false);
|
|
79
|
+
assert.deepEqual(result.answers, [
|
|
80
|
+
{ questionIndex: 0, question: "Which library?", kind: "option", answer: "dayjs" },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("单选:数字键直接选中并提交", () => {
|
|
85
|
+
const state = createState(singleParams());
|
|
86
|
+
const result = submitted(reduce(state, { type: "digit", digit: 3 }));
|
|
87
|
+
assert.equal(result.answers[0].answer, "luxon");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("单选:数字键落在哨兵行 → 进自由输入", () => {
|
|
91
|
+
const state = createState(singleParams());
|
|
92
|
+
const outcome = reduce(state, { type: "digit", digit: 4 });
|
|
93
|
+
assert.equal(outcome.status, "enterInput");
|
|
94
|
+
assert.equal(state.inputMode, true);
|
|
95
|
+
assert.equal(state.cursor, 3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("单选:越界数字键无效", () => {
|
|
99
|
+
const state = createState(singleParams());
|
|
100
|
+
const outcome = reduce(state, { type: "digit", digit: 9 });
|
|
101
|
+
assert.equal(outcome.status, "continue");
|
|
102
|
+
assert.equal(state.cursor, 0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("上下键 clamp 在 [0, rowCount-1]", () => {
|
|
106
|
+
const state = createState(singleParams());
|
|
107
|
+
reduce(state, { type: "up" });
|
|
108
|
+
assert.equal(state.cursor, 0);
|
|
109
|
+
for (let i = 0; i < 10; i++) reduce(state, { type: "down" });
|
|
110
|
+
assert.equal(state.cursor, 3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("单选题 Space 不产生勾选", () => {
|
|
114
|
+
const state = createState(singleParams());
|
|
115
|
+
const outcome = reduce(state, { type: "toggle" });
|
|
116
|
+
assert.equal(outcome.status, "continue");
|
|
117
|
+
assert.deepEqual(state.toggles[0], [false, false, false]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("多选:Space 勾选 + Enter 提交选中项", () => {
|
|
121
|
+
const state = createState(multiSelectParams());
|
|
122
|
+
reduce(state, { type: "toggle" }); // X
|
|
123
|
+
reduce(state, { type: "down" });
|
|
124
|
+
reduce(state, { type: "down" });
|
|
125
|
+
reduce(state, { type: "toggle" }); // Z
|
|
126
|
+
const result = submitted(reduce(state, { type: "confirm" }));
|
|
127
|
+
assert.deepEqual(result.answers[0], {
|
|
128
|
+
questionIndex: 0,
|
|
129
|
+
question: "Which features?",
|
|
130
|
+
kind: "multi",
|
|
131
|
+
answer: null,
|
|
132
|
+
selected: ["X", "Z"],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("多选:数字键切换勾选态并移动光标", () => {
|
|
137
|
+
const state = createState(multiSelectParams());
|
|
138
|
+
reduce(state, { type: "digit", digit: 2 });
|
|
139
|
+
assert.equal(state.cursor, 1);
|
|
140
|
+
assert.deepEqual(state.toggles[0], [false, true, false]);
|
|
141
|
+
reduce(state, { type: "digit", digit: 2 }); // 再按取消勾选
|
|
142
|
+
assert.deepEqual(state.toggles[0], [false, false, false]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("多选:一个都不勾直接 Enter → selected 为空数组也算提交", () => {
|
|
146
|
+
const state = createState(multiSelectParams());
|
|
147
|
+
const result = submitted(reduce(state, { type: "confirm" }));
|
|
148
|
+
assert.deepEqual(result.answers[0].selected, []);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("哨兵行 Enter → 进自由输入;输入文本提交为 custom", () => {
|
|
152
|
+
const state = createState(singleParams());
|
|
153
|
+
for (let i = 0; i < 3; i++) reduce(state, { type: "down" }); // 哨兵行
|
|
154
|
+
const enter = reduce(state, { type: "confirm" });
|
|
155
|
+
assert.equal(enter.status, "enterInput");
|
|
156
|
+
const result = submitted(reduce(state, { type: "inputSubmit", text: " use luxon " }));
|
|
157
|
+
assert.deepEqual(result.answers[0], {
|
|
158
|
+
questionIndex: 0,
|
|
159
|
+
question: "Which library?",
|
|
160
|
+
kind: "custom",
|
|
161
|
+
answer: "use luxon",
|
|
162
|
+
});
|
|
163
|
+
assert.equal(state.inputMode, false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("自由输入为空 → 退回选项列表,不产生答案", () => {
|
|
167
|
+
const state = createState(singleParams());
|
|
168
|
+
for (let i = 0; i < 3; i++) reduce(state, { type: "down" });
|
|
169
|
+
reduce(state, { type: "confirm" }); // enterInput
|
|
170
|
+
const outcome = reduce(state, { type: "inputSubmit", text: " " });
|
|
171
|
+
assert.equal(outcome.status, "exitInput");
|
|
172
|
+
assert.equal(state.inputMode, false);
|
|
173
|
+
assert.equal(state.answers[0], undefined);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("inputCancel 退回选项列表", () => {
|
|
177
|
+
const state = createState(singleParams());
|
|
178
|
+
for (let i = 0; i < 3; i++) reduce(state, { type: "down" });
|
|
179
|
+
reduce(state, { type: "confirm" });
|
|
180
|
+
const outcome = reduce(state, { type: "inputCancel" });
|
|
181
|
+
assert.equal(outcome.status, "exitInput");
|
|
182
|
+
assert.equal(state.inputMode, false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("取消:已答部分随 cancel 结果带出", () => {
|
|
186
|
+
const state = createState(twoParams());
|
|
187
|
+
reduce(state, { type: "confirm" }); // 答了 Q1
|
|
188
|
+
const outcome = reduce(state, { type: "cancel" });
|
|
189
|
+
assert.equal(outcome.status, "cancel");
|
|
190
|
+
if (outcome.status !== "cancel") throw new Error("unreachable");
|
|
191
|
+
assert.equal(outcome.result.cancelled, true);
|
|
192
|
+
assert.equal(outcome.result.answers.length, 1);
|
|
193
|
+
assert.equal(outcome.result.answers[0].answer, "a");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("多题:答完 Q1 自动到 Q2,答完 Q2 进提交页", () => {
|
|
197
|
+
const state = createState(twoParams());
|
|
198
|
+
assert.equal(state.currentTab, 0);
|
|
199
|
+
const after1 = reduce(state, { type: "confirm" });
|
|
200
|
+
assert.equal(after1.status, "continue");
|
|
201
|
+
assert.equal(state.currentTab, 1);
|
|
202
|
+
const after2 = reduce(state, { type: "confirm" });
|
|
203
|
+
assert.equal(after2.status, "continue");
|
|
204
|
+
assert.equal(isSubmitTab(state), true);
|
|
205
|
+
assert.equal(allAnswered(state), true);
|
|
206
|
+
const result = submitted(reduce(state, { type: "confirm" }));
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
result.answers.map((a) => a.answer),
|
|
209
|
+
["a", "c"],
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("提交页闸门:有未答题时 Enter 不提交", () => {
|
|
214
|
+
const state = createState(twoParams());
|
|
215
|
+
reduce(state, { type: "confirm" }); // Q1
|
|
216
|
+
reduce(state, { type: "nextTab" }); // 跳过 Q2 到提交页
|
|
217
|
+
assert.equal(isSubmitTab(state), true);
|
|
218
|
+
assert.deepEqual(unansweredHeaders(state), ["Two"]);
|
|
219
|
+
const outcome = reduce(state, { type: "confirm" });
|
|
220
|
+
assert.equal(outcome.status, "continue");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("Tab 环绕切换(含提交页),切页重置光标并按已有答案恢复", () => {
|
|
224
|
+
const state = createState(twoParams());
|
|
225
|
+
reduce(state, { type: "down" }); // Q1 cursor=1(选项 b)
|
|
226
|
+
reduce(state, { type: "nextTab" });
|
|
227
|
+
assert.equal(state.currentTab, 1);
|
|
228
|
+
assert.equal(state.cursor, 0);
|
|
229
|
+
reduce(state, { type: "nextTab" });
|
|
230
|
+
assert.equal(isSubmitTab(state), true);
|
|
231
|
+
reduce(state, { type: "nextTab" }); // 环绕回 Q1
|
|
232
|
+
assert.equal(state.currentTab, 0);
|
|
233
|
+
// Q1 尚无答案,光标回 0
|
|
234
|
+
assert.equal(state.cursor, 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("重答已答过的单选题:光标恢复到原选项", () => {
|
|
238
|
+
const state = createState(twoParams());
|
|
239
|
+
reduce(state, { type: "down" }); // cursor=1
|
|
240
|
+
reduce(state, { type: "confirm" }); // 答 b,进 Q2
|
|
241
|
+
reduce(state, { type: "prevTab" }); // 回 Q1
|
|
242
|
+
assert.equal(state.currentTab, 0);
|
|
243
|
+
assert.equal(state.cursor, 1); // 停在 b 上
|
|
244
|
+
// 重答覆盖旧值
|
|
245
|
+
const outcome = reduce(state, { type: "up" }); // cursor=0 → a
|
|
246
|
+
assert.equal(outcome.status, "continue");
|
|
247
|
+
reduce(state, { type: "confirm" });
|
|
248
|
+
assert.equal(state.answers[0]?.answer, "a");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("重答 custom 答案:光标恢复到哨兵行", () => {
|
|
252
|
+
const state = createState(twoParams());
|
|
253
|
+
reduce(state, { type: "down" });
|
|
254
|
+
reduce(state, { type: "down" }); // 哨兵行
|
|
255
|
+
reduce(state, { type: "confirm" }); // enterInput
|
|
256
|
+
reduce(state, { type: "inputSubmit", text: "custom" }); // 答完进 Q2
|
|
257
|
+
reduce(state, { type: "prevTab" });
|
|
258
|
+
assert.equal(state.cursor, 2); // Q1 的哨兵行 = options.length
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("提交页上数字键 / Space 无效", () => {
|
|
262
|
+
const state = createState(twoParams());
|
|
263
|
+
reduce(state, { type: "confirm" });
|
|
264
|
+
reduce(state, { type: "confirm" });
|
|
265
|
+
assert.equal(isSubmitTab(state), true);
|
|
266
|
+
assert.equal(reduce(state, { type: "digit", digit: 1 }).status, "continue");
|
|
267
|
+
assert.equal(reduce(state, { type: "toggle" }).status, "continue");
|
|
268
|
+
assert.equal(reduce(state, { type: "up" }).status, "continue");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("单题模式下 Tab 切换无效", () => {
|
|
272
|
+
const state = createState(singleParams());
|
|
273
|
+
assert.equal(reduce(state, { type: "nextTab" }).status, "continue");
|
|
274
|
+
assert.equal(state.currentTab, 0);
|
|
275
|
+
});
|