@zhushanwen/pi-ask-user 0.0.4 → 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/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
package/src/index.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { Box, Text, TruncatedText, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
|
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",
|
|
@@ -76,83 +232,58 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
|
|
|
76
232
|
_toolCallId: string,
|
|
77
233
|
params: Static<typeof InputSchema>,
|
|
78
234
|
signal: AbortSignal | undefined,
|
|
79
|
-
_onUpdate:
|
|
235
|
+
_onUpdate: AgentToolUpdateCallback<AskUserDetails> | undefined,
|
|
80
236
|
ctx: ExtensionContext,
|
|
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
|
-
// 3. Signal abort 入口检查(spec FR-10
|
|
256
|
+
// 3. Signal abort 入口检查(spec FR-10)。
|
|
257
|
+
// abort 是 agent 被外部终止(goal 取消 / context compact / session 切换),
|
|
258
|
+
// 不是用户意图——文案必须区别于 step 5 的用户取消,避免 LLM 俊等用户。
|
|
115
259
|
if (signal?.aborted) {
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
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.",
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
124
|
-
};
|
|
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
|
+
);
|
|
125
264
|
}
|
|
126
265
|
|
|
127
|
-
// 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";
|
|
128
270
|
let result: Result | null;
|
|
129
271
|
try {
|
|
130
|
-
result =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
questions,
|
|
134
|
-
tui as { requestRender(): void },
|
|
135
|
-
theme as ThemeLike,
|
|
136
|
-
done,
|
|
137
|
-
);
|
|
138
|
-
// signal abort 监听(spec FR-10):走组件 cancel() 复用 _resolved 守卫,
|
|
139
|
-
// 避免用户已 submit/cancel 后 signal 才 abort 二次调 done(FR-12 竞态)
|
|
140
|
-
if (signal) {
|
|
141
|
-
signal.addEventListener("abort", () => comp.cancel(), { once: true });
|
|
142
|
-
}
|
|
143
|
-
return comp;
|
|
144
|
-
},
|
|
145
|
-
// 不传 options → inline 渲染(spec FR-3)
|
|
146
|
-
);
|
|
272
|
+
result = useRpc
|
|
273
|
+
? await runRpcInteraction(questions, signal, ctx)
|
|
274
|
+
: await runTuiInteraction(questions, signal, ctx);
|
|
147
275
|
} catch (err) {
|
|
276
|
+
// RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
|
|
277
|
+
// TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
|
|
148
278
|
const message = err instanceof Error ? err.message : String(err);
|
|
279
|
+
if (useRpc) disableAskUser(pi);
|
|
149
280
|
return {
|
|
150
|
-
content: [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
],
|
|
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
|
+
}],
|
|
156
287
|
isError: true,
|
|
157
288
|
details: { error: message } satisfies ErrorDetails,
|
|
158
289
|
};
|
|
@@ -160,15 +291,10 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
|
|
|
160
291
|
|
|
161
292
|
// 5. 取消(null / cancelled)
|
|
162
293
|
if (result === null || result.cancelled) {
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
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.",
|
|
168
|
-
},
|
|
169
|
-
],
|
|
170
|
-
details: { questions, answers: {}, cancelled: true } satisfies Result,
|
|
171
|
-
};
|
|
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
|
+
);
|
|
172
298
|
}
|
|
173
299
|
|
|
174
300
|
// 6. 正常返回
|
package/src/question-view.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
isHighSurrogate,
|
|
5
6
|
OTHER_LABEL,
|
|
6
7
|
type Question,
|
|
7
8
|
type QuestionState,
|
|
@@ -9,18 +10,51 @@ import {
|
|
|
9
10
|
SPLIT_PANE_MIN_WIDTH,
|
|
10
11
|
SPLIT_PANE_RIGHT_MIN,
|
|
11
12
|
SPLIT_PANE_SEPARATOR,
|
|
13
|
+
SURROGATE_PAIR_LEN,
|
|
12
14
|
type ThemeLike,
|
|
13
15
|
} from "./types";
|
|
14
16
|
|
|
17
|
+
const SPLIT_PANE_LEFT_RATIO = 0.42;
|
|
18
|
+
const DESCRIPTION_INDENT_MULTI = 10;
|
|
19
|
+
const DESCRIPTION_INDENT_SINGLE = 8;
|
|
20
|
+
const PREVIEW_MIN_WIDTH = 10;
|
|
21
|
+
const QUESTION_TEXT_MARGIN = 2;
|
|
22
|
+
|
|
15
23
|
export interface DisplayOption {
|
|
16
24
|
label: string;
|
|
17
25
|
description?: string;
|
|
18
26
|
isOther?: boolean;
|
|
19
27
|
}
|
|
20
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
|
+
|
|
21
40
|
/** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
|
|
22
41
|
const MAX_EDITOR_LINES = 5;
|
|
23
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
|
|
45
|
+
* surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
|
|
46
|
+
* 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
|
|
47
|
+
* 光标在文本末尾(超出范围)时反色高亮一个空格占位。
|
|
48
|
+
*/
|
|
49
|
+
function renderCursorText(text: string, cursorPos: number): string {
|
|
50
|
+
const before = text.slice(0, cursorPos);
|
|
51
|
+
// 光标在高代理前 → 反色高亮整个 surrogate pair
|
|
52
|
+
const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
|
|
53
|
+
const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
|
|
54
|
+
const after = text.slice(cursorPos + charLen);
|
|
55
|
+
return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
/**
|
|
25
59
|
* 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
|
|
26
60
|
* - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
|
|
@@ -29,7 +63,7 @@ const MAX_EDITOR_LINES = 5;
|
|
|
29
63
|
*
|
|
30
64
|
* @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
|
|
31
65
|
* @param lead 首行前缀(含选中标记 / 勾选框)
|
|
32
|
-
* @param content 待换行展示的已样式化文本(可含 ANSI +
|
|
66
|
+
* @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
|
|
33
67
|
* @param availWidth 单行可用宽度
|
|
34
68
|
* @param maxLines 最多行数
|
|
35
69
|
*/
|
|
@@ -71,7 +105,7 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
71
105
|
if (width < SPLIT_PANE_MIN_WIDTH) return null;
|
|
72
106
|
const available = width - SPLIT_PANE_SEPARATOR.length;
|
|
73
107
|
if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
|
|
74
|
-
const preferredLeft = Math.floor(available *
|
|
108
|
+
const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
|
|
75
109
|
const left = Math.max(
|
|
76
110
|
SPLIT_PANE_LEFT_MIN,
|
|
77
111
|
Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
|
|
@@ -81,18 +115,18 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
81
115
|
return { left, right };
|
|
82
116
|
}
|
|
83
117
|
|
|
118
|
+
/** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
|
|
119
|
+
* 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
|
|
120
|
+
const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
|
|
121
|
+
|
|
84
122
|
/** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
|
|
85
|
-
* freeform 模式下,Other 行**原地**变 [ ] <input
|
|
123
|
+
* freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
|
|
86
124
|
* 不再依赖 buildEditorBlock 的下方独立编辑块。 */
|
|
87
125
|
function buildOptionLines(
|
|
88
|
-
|
|
89
|
-
state: QuestionState,
|
|
90
|
-
theme: ThemeLike,
|
|
91
|
-
width: number,
|
|
126
|
+
ctx: RenderContext,
|
|
92
127
|
hideDescriptions: boolean,
|
|
93
|
-
editorText: string = "",
|
|
94
128
|
): string[] {
|
|
95
|
-
const t =
|
|
129
|
+
const { question: q, state, theme: t, width } = ctx;
|
|
96
130
|
const opts = allOptions(q);
|
|
97
131
|
const lines: string[] = [];
|
|
98
132
|
const add = (s: string): void => {
|
|
@@ -101,7 +135,9 @@ function buildOptionLines(
|
|
|
101
135
|
|
|
102
136
|
for (let i = 0; i < opts.length; i++) {
|
|
103
137
|
const opt = opts[i]!;
|
|
104
|
-
|
|
138
|
+
// 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
|
|
139
|
+
const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
|
|
140
|
+
const isSelected = i === activeOptionCursor;
|
|
105
141
|
const isOther = opt.isOther === true;
|
|
106
142
|
const prefix = isSelected ? t.fg("accent", ">") : " ";
|
|
107
143
|
|
|
@@ -115,15 +151,16 @@ function buildOptionLines(
|
|
|
115
151
|
const num = i + 1;
|
|
116
152
|
const lead = `${prefix} ${marker} `;
|
|
117
153
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
118
|
-
// 编号 +
|
|
119
|
-
const
|
|
154
|
+
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
155
|
+
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
156
|
+
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
120
157
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
121
158
|
} else {
|
|
122
159
|
const hasFreeText = state.freeTextValue !== null;
|
|
123
160
|
const marker = q.multiSelect
|
|
124
161
|
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
|
|
125
162
|
: (hasFreeText ? t.fg("success", "✓") : " ");
|
|
126
|
-
const labelColor = isSelected ? "accent" : "
|
|
163
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
127
164
|
const num = i + 1;
|
|
128
165
|
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
129
166
|
if (hasFreeText) {
|
|
@@ -145,7 +182,7 @@ function buildOptionLines(
|
|
|
145
182
|
const num = i + 1;
|
|
146
183
|
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
147
184
|
if (opt.description && !hideDescriptions) {
|
|
148
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
185
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
|
|
149
186
|
for (const line of wrapped) add(` ${line}`);
|
|
150
187
|
}
|
|
151
188
|
} else {
|
|
@@ -155,7 +192,7 @@ function buildOptionLines(
|
|
|
155
192
|
const num = i + 1;
|
|
156
193
|
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
157
194
|
if (opt.description && !hideDescriptions) {
|
|
158
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
195
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
|
|
159
196
|
for (const line of wrapped) add(` ${line}`);
|
|
160
197
|
}
|
|
161
198
|
}
|
|
@@ -165,13 +202,10 @@ function buildOptionLines(
|
|
|
165
202
|
|
|
166
203
|
/** 构建分屏右侧 Markdown 详情预览。 */
|
|
167
204
|
function buildPreviewLines(
|
|
168
|
-
|
|
169
|
-
state: QuestionState,
|
|
170
|
-
theme: ThemeLike,
|
|
171
|
-
width: number,
|
|
205
|
+
ctx: RenderContext,
|
|
172
206
|
maxLines: number,
|
|
173
207
|
): string[] {
|
|
174
|
-
const t =
|
|
208
|
+
const { question: q, state, theme: t, width } = ctx;
|
|
175
209
|
const opts = allOptions(q);
|
|
176
210
|
const opt = opts[state.cursorIndex];
|
|
177
211
|
if (!opt) return [t.fg("dim", "—")];
|
|
@@ -184,32 +218,24 @@ function buildPreviewLines(
|
|
|
184
218
|
if (opt.description?.trim()) text += `\n\n${opt.description}`;
|
|
185
219
|
}
|
|
186
220
|
|
|
187
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(
|
|
221
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
|
|
188
222
|
const lines = wrapped.slice(0, maxLines);
|
|
189
223
|
if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
|
|
190
224
|
return lines;
|
|
191
225
|
}
|
|
192
226
|
|
|
193
227
|
/**
|
|
194
|
-
*
|
|
195
|
-
* isSingle: 单问题模式(无 Tab 提示)。
|
|
196
|
-
* editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
|
|
197
|
-
*/
|
|
198
|
-
/**
|
|
199
|
-
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
|
|
228
|
+
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
|
|
200
229
|
* buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
|
|
201
230
|
* comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
|
|
202
231
|
*/
|
|
203
232
|
function buildEditorBlock(
|
|
204
|
-
|
|
205
|
-
width: number,
|
|
206
|
-
mode: "freeform" | "comment",
|
|
207
|
-
editorText: string,
|
|
233
|
+
ctx: RenderContext,
|
|
208
234
|
): string[] {
|
|
209
|
-
|
|
235
|
+
const { state, theme: t, width } = ctx;
|
|
236
|
+
if (state.mode === "freeform") {
|
|
210
237
|
return [""];
|
|
211
238
|
}
|
|
212
|
-
const t = theme;
|
|
213
239
|
const lines: string[] = [];
|
|
214
240
|
const add = (s: string): void => {
|
|
215
241
|
lines.push(truncateToWidth(s, width));
|
|
@@ -217,31 +243,28 @@ function buildEditorBlock(
|
|
|
217
243
|
add("");
|
|
218
244
|
const prompt = t.fg("muted", " Your comment (optional):");
|
|
219
245
|
add(prompt);
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
add(` ${t.fg("accent", "█")}`);
|
|
246
|
+
// 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
|
|
247
|
+
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
248
|
+
add(` ${t.fg("text", cursorText)}`);
|
|
224
249
|
add("");
|
|
225
|
-
add(t.fg("dim",
|
|
250
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
226
251
|
return lines;
|
|
227
252
|
}
|
|
228
253
|
|
|
229
254
|
/** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
|
|
230
255
|
function buildSplitPane(
|
|
231
|
-
|
|
232
|
-
state: QuestionState,
|
|
233
|
-
theme: ThemeLike,
|
|
256
|
+
ctx: RenderContext,
|
|
234
257
|
split: { left: number; right: number },
|
|
235
|
-
width: number,
|
|
236
|
-
editorText: string = "",
|
|
237
258
|
): string[] {
|
|
238
|
-
const t =
|
|
259
|
+
const { theme: t, width } = ctx;
|
|
239
260
|
const lines: string[] = [];
|
|
240
261
|
const add = (s: string): void => {
|
|
241
262
|
lines.push(truncateToWidth(s, width));
|
|
242
263
|
};
|
|
243
|
-
const
|
|
244
|
-
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));
|
|
245
268
|
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
246
269
|
const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
247
270
|
for (let i = 0; i < rowCount; i++) {
|
|
@@ -252,15 +275,11 @@ function buildSplitPane(
|
|
|
252
275
|
return lines;
|
|
253
276
|
}
|
|
254
277
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
isSingle: boolean,
|
|
261
|
-
editorText: string,
|
|
262
|
-
): string[] {
|
|
263
|
-
const t = theme;
|
|
278
|
+
/**
|
|
279
|
+
* 渲染单个问题视图(spec FR-4)。
|
|
280
|
+
*/
|
|
281
|
+
export function renderQuestionView(ctx: RenderContext): string[] {
|
|
282
|
+
const { question: q, state, theme: t, width, isSingle } = ctx;
|
|
264
283
|
const lines: string[] = [];
|
|
265
284
|
const add = (s: string): void => {
|
|
266
285
|
lines.push(truncateToWidth(s, width));
|
|
@@ -268,13 +287,13 @@ export function renderQuestionView(
|
|
|
268
287
|
const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
|
|
269
288
|
|
|
270
289
|
// 问题文本(word-wrap)
|
|
271
|
-
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width -
|
|
290
|
+
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
|
|
272
291
|
for (const line of wrapped) add(line);
|
|
273
292
|
|
|
274
293
|
// 上下文(如有)
|
|
275
294
|
if (q.context?.trim()) {
|
|
276
295
|
divider();
|
|
277
|
-
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width -
|
|
296
|
+
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
|
|
278
297
|
for (const line of ctxWrapped) add(line);
|
|
279
298
|
}
|
|
280
299
|
|
|
@@ -292,24 +311,21 @@ export function renderQuestionView(
|
|
|
292
311
|
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
293
312
|
if (state.mode === "freeform" || state.mode === "comment") {
|
|
294
313
|
add("");
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
const editorBlock = buildEditorBlock(theme, width, state.mode, editorText);
|
|
298
|
-
lines.push(...editorBlock);
|
|
314
|
+
for (const line of buildOptionLines(ctx, false)) add(line);
|
|
315
|
+
lines.push(...buildEditorBlock(ctx));
|
|
299
316
|
if (state.mode === "freeform") {
|
|
300
317
|
// freeform 模式 help 行:光标锁在 Other 上,正在输入
|
|
301
|
-
add(t.fg("dim",
|
|
318
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
302
319
|
}
|
|
303
320
|
return lines;
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
if (!split) {
|
|
307
324
|
// 单列模式
|
|
308
|
-
const
|
|
309
|
-
for (const line of optionLines) add(line);
|
|
325
|
+
for (const line of buildOptionLines(ctx, false)) add(line);
|
|
310
326
|
} else {
|
|
311
327
|
// 分屏模式
|
|
312
|
-
lines.push(...buildSplitPane(
|
|
328
|
+
lines.push(...buildSplitPane(ctx, split));
|
|
313
329
|
}
|
|
314
330
|
|
|
315
331
|
add("");
|