@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/src/index.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
+
import { Box, Text, TruncatedText, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
|
|
4
4
|
import { type Static } from "@sinclair/typebox";
|
|
5
|
+
import {
|
|
6
|
+
type AskUserAnswers,
|
|
7
|
+
askUserInteract,
|
|
8
|
+
type AskUserQuestion,
|
|
9
|
+
getAskUserAnswer,
|
|
10
|
+
getAskUserComment,
|
|
11
|
+
getAskUserOther,
|
|
12
|
+
} from "@xyz-agent/extension-protocol";
|
|
5
13
|
|
|
6
14
|
import { AskUserComponent } from "./component";
|
|
15
|
+
import { formatAnswer, parseAnswerParts } from "./answer-format";
|
|
7
16
|
import {
|
|
8
17
|
type AskUserDetails,
|
|
9
18
|
type ErrorDetails,
|
|
@@ -22,9 +31,53 @@ import { validateInput } from "./validate";
|
|
|
22
31
|
*/
|
|
23
32
|
type ExecuteResult = AgentToolResult<AskUserDetails> & { isError?: boolean };
|
|
24
33
|
|
|
34
|
+
/** 禁用 ask_user 工具(headless / RPC 通道不可用时调用,防 LLM 反复重试)。 */
|
|
35
|
+
function disableAskUser(pi: ExtensionAPI): void {
|
|
36
|
+
pi.setActiveTools(
|
|
37
|
+
pi
|
|
38
|
+
.getAllTools()
|
|
39
|
+
.map((t: { name: string }) => t.name)
|
|
40
|
+
.filter((n: string) => n !== "ask_user"),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 构造 cancelled 结果(用户取消 / abort / 校验失败共用)。 */
|
|
45
|
+
function cancelledResult(questions: Question[], text: string, isError = false): ExecuteResult {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text" as const, text }],
|
|
48
|
+
isError: isError || undefined,
|
|
49
|
+
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** TUI 模式交互(自定义 Component 实时交互)。 */
|
|
54
|
+
async function runTuiInteraction(
|
|
55
|
+
questions: Question[],
|
|
56
|
+
signal: AbortSignal | undefined,
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
): Promise<Result | null> {
|
|
59
|
+
return ctx.ui.custom<Result | null>(
|
|
60
|
+
(tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
|
|
61
|
+
const comp = new AskUserComponent(
|
|
62
|
+
questions,
|
|
63
|
+
tui as { requestRender(): void },
|
|
64
|
+
theme as ThemeLike,
|
|
65
|
+
done,
|
|
66
|
+
);
|
|
67
|
+
// signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
|
|
68
|
+
// 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
|
|
69
|
+
if (signal) {
|
|
70
|
+
signal.addEventListener("abort", () => comp.cancel(), { once: true });
|
|
71
|
+
}
|
|
72
|
+
return comp;
|
|
73
|
+
},
|
|
74
|
+
// 不传 options → inline 渲染(spec FR-3)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
/**
|
|
26
79
|
* expanded 渲染辅助:展开某问题的全部选项,用 ●/○ 标记是否被选中(spec FR-9)。
|
|
27
|
-
*
|
|
80
|
+
* 选中判定:用 parseAnswerParts 精确匹配 label(而非子串匹配),避免 "A" 是 "AB" 子串时的误判。
|
|
28
81
|
* 返回 TruncatedText 数组供 box.addChild 展开。
|
|
29
82
|
*/
|
|
30
83
|
function renderExpandedOptions(
|
|
@@ -32,9 +85,11 @@ function renderExpandedOptions(
|
|
|
32
85
|
answer: string,
|
|
33
86
|
theme: ThemeLike,
|
|
34
87
|
): TruncatedText[] {
|
|
35
|
-
const
|
|
88
|
+
const labels = q.options.map((o: Option) => o.label);
|
|
89
|
+
const { selected } = parseAnswerParts(answer, labels);
|
|
90
|
+
const selectedSet = new Set(selected);
|
|
36
91
|
const mark = (opt: Option): string =>
|
|
37
|
-
|
|
92
|
+
selectedSet.has(opt.label)
|
|
38
93
|
? theme.fg("success", "●")
|
|
39
94
|
: theme.fg("dim", "○");
|
|
40
95
|
// 只展开真实选项(不含自动追加的 Other);Other 文本单独显示
|
|
@@ -51,6 +106,107 @@ function renderExpandedOptions(
|
|
|
51
106
|
return out;
|
|
52
107
|
}
|
|
53
108
|
|
|
109
|
+
/**
|
|
110
|
+
* 把 ask-user 内部 Question[] 映射为协议包 AskUserQuestion[](RPC 交互声明)。
|
|
111
|
+
*
|
|
112
|
+
* ask-user 的 Question.options 必填且只有 label/description(无 value 字段),
|
|
113
|
+
* 协议的 AskUserOption.value 缺失时前端用 label 做回传值——与 ask-user 语义一致
|
|
114
|
+
* (TUI 版 buildResult 也是用 label 拼 answers)。
|
|
115
|
+
* allowOther 固定 true:ask-user 无条件自动追加 Other(schema 不暴露此字段)。
|
|
116
|
+
*/
|
|
117
|
+
function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
|
|
118
|
+
return questions.map((q: Question) => ({
|
|
119
|
+
header: q.header,
|
|
120
|
+
question: q.question,
|
|
121
|
+
context: q.context,
|
|
122
|
+
options: q.options.map((o: Option) => ({
|
|
123
|
+
label: o.label,
|
|
124
|
+
value: o.label, // ask-user 用 label 做回传值(与 TUI buildResult 语义一致)
|
|
125
|
+
description: o.description,
|
|
126
|
+
})),
|
|
127
|
+
multiSelect: q.multiSelect,
|
|
128
|
+
allowOther: true,
|
|
129
|
+
allowComment: q.allowComment ?? false,
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
|
|
135
|
+
*
|
|
136
|
+
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment
|
|
137
|
+
* ask-user 格式:key=question 全文, value=逗号分隔 label + Other, comment 内联(` — `)
|
|
138
|
+
*
|
|
139
|
+
* 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
|
|
140
|
+
* 确保 RPC 和 TUI 两条路径产出的 Result.answers 格式一致。
|
|
141
|
+
*/
|
|
142
|
+
function protoAnswersToResult(
|
|
143
|
+
questions: Question[],
|
|
144
|
+
protoQuestions: AskUserQuestion[],
|
|
145
|
+
answers: AskUserAnswers,
|
|
146
|
+
): Result["answers"] {
|
|
147
|
+
const out: Record<string, string> = {};
|
|
148
|
+
for (let i = 0; i < questions.length; i++) {
|
|
149
|
+
const q = questions[i]!;
|
|
150
|
+
const iq = protoQuestions[i]!;
|
|
151
|
+
const selected = getAskUserAnswer(answers, iq);
|
|
152
|
+
const other = getAskUserOther(answers, iq);
|
|
153
|
+
const comment = getAskUserComment(answers, iq);
|
|
154
|
+
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
if (Array.isArray(selected)) {
|
|
157
|
+
// 多选按 question.options 中的定义顺序排序(S#3),
|
|
158
|
+
// 与 TUI 版 submit-view.ts 的 selectedIndices.sort() 语义一致,
|
|
159
|
+
// 确保 RPC 和 TUI 产出相同文本("A, C" 而非前端回传顺序的 "C, A")。
|
|
160
|
+
const orderMap = new Map(q.options.map((o: Option, idx: number) => [o.label, idx]));
|
|
161
|
+
const unknownOrder = q.options.length;
|
|
162
|
+
parts.push(...[...selected].sort((a, b) => {
|
|
163
|
+
const ai = orderMap.get(a) ?? unknownOrder;
|
|
164
|
+
const bi = orderMap.get(b) ?? unknownOrder;
|
|
165
|
+
return ai - bi;
|
|
166
|
+
}));
|
|
167
|
+
} else if (selected) {
|
|
168
|
+
parts.push(selected);
|
|
169
|
+
}
|
|
170
|
+
if (other) parts.push(other);
|
|
171
|
+
const formatted = formatAnswer(parts, comment);
|
|
172
|
+
if (formatted !== null) out[q.question] = formatted;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* RPC 模式(xyz-agent GUI)交互入口。
|
|
179
|
+
*
|
|
180
|
+
* 走 askUserInteract(select 通道 + ASK_USER_MARKER),前端 AskUserOverlay 渲染富交互 UI。
|
|
181
|
+
* 返回 Result(正常/取消),或抛错(select 异常 / 非 RPC 模式调用了此函数)。
|
|
182
|
+
*
|
|
183
|
+
* 注意:从 ExtensionContext 构造 GuiContext 子集传入,而非直接传 ctx——
|
|
184
|
+
* ExtensionContext.ui.custom 的泛型签名与 GuiContext.ui.custom 不兼容(前者复杂泛型,后者简化签名),
|
|
185
|
+
* 直接传会导致类型不兼容。GuiContext 只需要 mode/hasUI/ui.select,构造最小子集避免签名冲突。
|
|
186
|
+
*/
|
|
187
|
+
async function runRpcInteraction(
|
|
188
|
+
questions: Question[],
|
|
189
|
+
signal: AbortSignal | undefined,
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
): Promise<Result> {
|
|
192
|
+
const protoQuestions = toProtoQuestions(questions);
|
|
193
|
+
const guiCtx = {
|
|
194
|
+
mode: ctx.mode,
|
|
195
|
+
hasUI: ctx.hasUI,
|
|
196
|
+
ui: { select: ctx.ui.select.bind(ctx.ui) },
|
|
197
|
+
};
|
|
198
|
+
const answers = await askUserInteract(guiCtx, protoQuestions, { signal, allowCancel: true });
|
|
199
|
+
|
|
200
|
+
if (answers === null) {
|
|
201
|
+
return { questions, answers: {}, cancelled: true };
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
questions,
|
|
205
|
+
answers: protoAnswersToResult(questions, protoQuestions, answers),
|
|
206
|
+
cancelled: false,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
54
210
|
export default function (pi: ExtensionAPI): void {
|
|
55
211
|
pi.registerTool({
|
|
56
212
|
name: "ask_user",
|
|
@@ -81,80 +237,53 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
|
|
|
81
237
|
): Promise<ExecuteResult> {
|
|
82
238
|
const questions = params.questions;
|
|
83
239
|
|
|
84
|
-
// 1. 参数校验(spec FR-2
|
|
240
|
+
// 1. 参数校验(spec FR-2)
|
|
85
241
|
const validationError = validateInput(questions);
|
|
86
242
|
if (validationError) {
|
|
87
|
-
return {
|
|
88
|
-
content: [{ type: "text" as const, text: `Error: ${validationError}` }],
|
|
89
|
-
isError: true,
|
|
90
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
91
|
-
};
|
|
243
|
+
return cancelledResult(questions, `Error: ${validationError}`, true);
|
|
92
244
|
}
|
|
93
245
|
|
|
94
|
-
// 2. Headless 检查(spec FR-8
|
|
95
|
-
if (
|
|
96
|
-
pi
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
246
|
+
// 2. Headless 检查(spec FR-8):mode 非 tui 非 rpc = 无交互通道
|
|
247
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
|
|
248
|
+
disableAskUser(pi);
|
|
249
|
+
return cancelledResult(
|
|
250
|
+
questions,
|
|
251
|
+
"Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
|
|
252
|
+
true,
|
|
101
253
|
);
|
|
102
|
-
return {
|
|
103
|
-
content: [
|
|
104
|
-
{
|
|
105
|
-
type: "text" as const,
|
|
106
|
-
text: "Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
|
|
107
|
-
},
|
|
108
|
-
],
|
|
109
|
-
isError: true,
|
|
110
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
111
|
-
};
|
|
112
254
|
}
|
|
113
255
|
|
|
114
256
|
// 3. Signal abort 入口检查(spec FR-10)。
|
|
115
257
|
// abort 是 agent 被外部终止(goal 取消 / context compact / session 切换),
|
|
116
258
|
// 不是用户意图——文案必须区别于 step 5 的用户取消,避免 LLM 俊等用户。
|
|
117
259
|
if (signal?.aborted) {
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
text: "Agent aborted (goal cancelled, context compacted, or session switched). Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required.",
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
126
|
-
};
|
|
260
|
+
return cancelledResult(
|
|
261
|
+
questions,
|
|
262
|
+
"Agent aborted (goal cancelled, context compacted, or session switched). Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required.",
|
|
263
|
+
);
|
|
127
264
|
}
|
|
128
265
|
|
|
129
|
-
// 4.
|
|
266
|
+
// 4. 交互执行:TUI 走 ctx.ui.custom,RPC(xyz-agent GUI)走 askUserInteract。
|
|
267
|
+
// 注意:hasUI 在 TUI 和 RPC 模式都为 true(dialog-capable),不能用于区分——
|
|
268
|
+
// 用 ctx.mode === 'rpc' 判定 GUI 渲染通道。
|
|
269
|
+
const useRpc = ctx.mode === "rpc";
|
|
130
270
|
let result: Result | null;
|
|
131
271
|
try {
|
|
132
|
-
result =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
questions,
|
|
136
|
-
tui as { requestRender(): void },
|
|
137
|
-
theme as ThemeLike,
|
|
138
|
-
done,
|
|
139
|
-
);
|
|
140
|
-
// signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
|
|
141
|
-
// 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
|
|
142
|
-
if (signal) {
|
|
143
|
-
signal.addEventListener("abort", () => comp.cancel(), { once: true });
|
|
144
|
-
}
|
|
145
|
-
return comp;
|
|
146
|
-
},
|
|
147
|
-
// 不传 options → inline 渲染(spec FR-3)
|
|
148
|
-
);
|
|
272
|
+
result = useRpc
|
|
273
|
+
? await runRpcInteraction(questions, signal, ctx)
|
|
274
|
+
: await runTuiInteraction(questions, signal, ctx);
|
|
149
275
|
} catch (err) {
|
|
276
|
+
// RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
|
|
277
|
+
// TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
|
|
150
278
|
const message = err instanceof Error ? err.message : String(err);
|
|
279
|
+
if (useRpc) disableAskUser(pi);
|
|
151
280
|
return {
|
|
152
|
-
content: [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
],
|
|
281
|
+
content: [{
|
|
282
|
+
type: "text" as const,
|
|
283
|
+
text: useRpc
|
|
284
|
+
? `ask_user failed: ${message}. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.`
|
|
285
|
+
: `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
|
|
286
|
+
}],
|
|
158
287
|
isError: true,
|
|
159
288
|
details: { error: message } satisfies ErrorDetails,
|
|
160
289
|
};
|
|
@@ -162,15 +291,10 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
|
|
|
162
291
|
|
|
163
292
|
// 5. 取消(null / cancelled)
|
|
164
293
|
if (result === null || result.cancelled) {
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
text: "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
|
|
170
|
-
},
|
|
171
|
-
],
|
|
172
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
173
|
-
};
|
|
294
|
+
return cancelledResult(
|
|
295
|
+
questions,
|
|
296
|
+
"User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
|
|
297
|
+
);
|
|
174
298
|
}
|
|
175
299
|
|
|
176
300
|
// 6. 正常返回
|
package/src/question-view.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface DisplayOption {
|
|
|
26
26
|
isOther?: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** 渲染上下文——收拢 q/state/theme/width 四个总是成对出现的参数。
|
|
30
|
+
* draftText 从 state.draftText 取(不再重复传递)。 */
|
|
31
|
+
export interface RenderContext {
|
|
32
|
+
question: Question;
|
|
33
|
+
state: QuestionState;
|
|
34
|
+
theme: ThemeLike;
|
|
35
|
+
width: number;
|
|
36
|
+
/** 单问题模式(无 Tab 提示)。 */
|
|
37
|
+
isSingle: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
|
|
30
41
|
const MAX_EDITOR_LINES = 5;
|
|
31
42
|
|
|
@@ -112,14 +123,10 @@ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit
|
|
|
112
123
|
* freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
|
|
113
124
|
* 不再依赖 buildEditorBlock 的下方独立编辑块。 */
|
|
114
125
|
function buildOptionLines(
|
|
115
|
-
|
|
116
|
-
state: QuestionState,
|
|
117
|
-
theme: ThemeLike,
|
|
118
|
-
width: number,
|
|
126
|
+
ctx: RenderContext,
|
|
119
127
|
hideDescriptions: boolean,
|
|
120
|
-
draftText: string = "",
|
|
121
128
|
): string[] {
|
|
122
|
-
const t =
|
|
129
|
+
const { question: q, state, theme: t, width } = ctx;
|
|
123
130
|
const opts = allOptions(q);
|
|
124
131
|
const lines: string[] = [];
|
|
125
132
|
const add = (s: string): void => {
|
|
@@ -145,7 +152,7 @@ function buildOptionLines(
|
|
|
145
152
|
const lead = `${prefix} ${marker} `;
|
|
146
153
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
147
154
|
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
148
|
-
const cursorText = renderCursorText(draftText, state.cursorIndex);
|
|
155
|
+
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
149
156
|
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
150
157
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
151
158
|
} else {
|
|
@@ -195,13 +202,10 @@ function buildOptionLines(
|
|
|
195
202
|
|
|
196
203
|
/** 构建分屏右侧 Markdown 详情预览。 */
|
|
197
204
|
function buildPreviewLines(
|
|
198
|
-
|
|
199
|
-
state: QuestionState,
|
|
200
|
-
theme: ThemeLike,
|
|
201
|
-
width: number,
|
|
205
|
+
ctx: RenderContext,
|
|
202
206
|
maxLines: number,
|
|
203
207
|
): string[] {
|
|
204
|
-
const t =
|
|
208
|
+
const { question: q, state, theme: t, width } = ctx;
|
|
205
209
|
const opts = allOptions(q);
|
|
206
210
|
const opt = opts[state.cursorIndex];
|
|
207
211
|
if (!opt) return [t.fg("dim", "—")];
|
|
@@ -226,16 +230,12 @@ function buildPreviewLines(
|
|
|
226
230
|
* comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
|
|
227
231
|
*/
|
|
228
232
|
function buildEditorBlock(
|
|
229
|
-
|
|
230
|
-
width: number,
|
|
231
|
-
mode: "freeform" | "comment",
|
|
232
|
-
draftText: string,
|
|
233
|
-
cursorIndex?: number,
|
|
233
|
+
ctx: RenderContext,
|
|
234
234
|
): string[] {
|
|
235
|
-
|
|
235
|
+
const { state, theme: t, width } = ctx;
|
|
236
|
+
if (state.mode === "freeform") {
|
|
236
237
|
return [""];
|
|
237
238
|
}
|
|
238
|
-
const t = theme;
|
|
239
239
|
const lines: string[] = [];
|
|
240
240
|
const add = (s: string): void => {
|
|
241
241
|
lines.push(truncateToWidth(s, width));
|
|
@@ -244,8 +244,7 @@ function buildEditorBlock(
|
|
|
244
244
|
const prompt = t.fg("muted", " Your comment (optional):");
|
|
245
245
|
add(prompt);
|
|
246
246
|
// 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
|
|
247
|
-
const
|
|
248
|
-
const cursorText = renderCursorText(draftText, pos);
|
|
247
|
+
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
249
248
|
add(` ${t.fg("text", cursorText)}`);
|
|
250
249
|
add("");
|
|
251
250
|
add(t.fg("dim", EDITOR_HINT));
|
|
@@ -254,20 +253,18 @@ function buildEditorBlock(
|
|
|
254
253
|
|
|
255
254
|
/** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
|
|
256
255
|
function buildSplitPane(
|
|
257
|
-
|
|
258
|
-
state: QuestionState,
|
|
259
|
-
theme: ThemeLike,
|
|
256
|
+
ctx: RenderContext,
|
|
260
257
|
split: { left: number; right: number },
|
|
261
|
-
width: number,
|
|
262
|
-
draftText: string = "",
|
|
263
258
|
): string[] {
|
|
264
|
-
const t =
|
|
259
|
+
const { theme: t, width } = ctx;
|
|
265
260
|
const lines: string[] = [];
|
|
266
261
|
const add = (s: string): void => {
|
|
267
262
|
lines.push(truncateToWidth(s, width));
|
|
268
263
|
};
|
|
269
|
-
const
|
|
270
|
-
const
|
|
264
|
+
const leftCtx: RenderContext = { ...ctx, width: split.left };
|
|
265
|
+
const rightCtx: RenderContext = { ...ctx, width: split.right };
|
|
266
|
+
const leftLines = buildOptionLines(leftCtx, true);
|
|
267
|
+
const rightLines = buildPreviewLines(rightCtx, Math.max(leftLines.length, 8));
|
|
271
268
|
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
272
269
|
const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
273
270
|
for (let i = 0; i < rowCount; i++) {
|
|
@@ -280,18 +277,9 @@ function buildSplitPane(
|
|
|
280
277
|
|
|
281
278
|
/**
|
|
282
279
|
* 渲染单个问题视图(spec FR-4)。
|
|
283
|
-
* isSingle: 单问题模式(无 Tab 提示)。
|
|
284
|
-
* draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
|
|
285
280
|
*/
|
|
286
|
-
export function renderQuestionView(
|
|
287
|
-
q:
|
|
288
|
-
state: QuestionState,
|
|
289
|
-
theme: ThemeLike,
|
|
290
|
-
width: number,
|
|
291
|
-
isSingle: boolean,
|
|
292
|
-
draftText: string,
|
|
293
|
-
): string[] {
|
|
294
|
-
const t = theme;
|
|
281
|
+
export function renderQuestionView(ctx: RenderContext): string[] {
|
|
282
|
+
const { question: q, state, theme: t, width, isSingle } = ctx;
|
|
295
283
|
const lines: string[] = [];
|
|
296
284
|
const add = (s: string): void => {
|
|
297
285
|
lines.push(truncateToWidth(s, width));
|
|
@@ -323,10 +311,8 @@ export function renderQuestionView(
|
|
|
323
311
|
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
324
312
|
if (state.mode === "freeform" || state.mode === "comment") {
|
|
325
313
|
add("");
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
|
|
329
|
-
lines.push(...editorBlock);
|
|
314
|
+
for (const line of buildOptionLines(ctx, false)) add(line);
|
|
315
|
+
lines.push(...buildEditorBlock(ctx));
|
|
330
316
|
if (state.mode === "freeform") {
|
|
331
317
|
// freeform 模式 help 行:光标锁在 Other 上,正在输入
|
|
332
318
|
add(t.fg("dim", EDITOR_HINT));
|
|
@@ -336,11 +322,10 @@ export function renderQuestionView(
|
|
|
336
322
|
|
|
337
323
|
if (!split) {
|
|
338
324
|
// 单列模式
|
|
339
|
-
const
|
|
340
|
-
for (const line of optionLines) add(line);
|
|
325
|
+
for (const line of buildOptionLines(ctx, false)) add(line);
|
|
341
326
|
} else {
|
|
342
327
|
// 分屏模式
|
|
343
|
-
lines.push(...buildSplitPane(
|
|
328
|
+
lines.push(...buildSplitPane(ctx, split));
|
|
344
329
|
}
|
|
345
330
|
|
|
346
331
|
add("");
|
package/src/submit-view.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/submit-view.ts
|
|
2
2
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
|
+
import { formatAnswer } from "./answer-format";
|
|
4
5
|
import {
|
|
5
|
-
ANSWER_COMMENT_SEPARATOR,
|
|
6
6
|
HEADER_MAX_CHARS,
|
|
7
7
|
type Question,
|
|
8
8
|
type QuestionState,
|
|
@@ -44,9 +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
|
-
|
|
48
|
-
const base = parts.join(", ");
|
|
49
|
-
return s.commentValue ? `${base}${ANSWER_COMMENT_SEPARATOR}${s.commentValue}` : base;
|
|
47
|
+
return formatAnswer(parts, s.commentValue);
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -55,6 +55,17 @@ export function validateInput(questions: Question[]): string | null {
|
|
|
55
55
|
return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars.`;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
// S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
|
|
60
|
+
// 后一个 question 的 __other/__comment 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
|
|
61
|
+
const seenHeaders = new Set<string>();
|
|
62
|
+
for (const q of questions) {
|
|
63
|
+
const h = q.header!.trim();
|
|
64
|
+
if (seenHeaders.has(h)) {
|
|
65
|
+
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.`;
|
|
66
|
+
}
|
|
67
|
+
seenHeaders.add(h);
|
|
68
|
+
}
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
// 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
|