@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/src/index.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  // src/index.ts
2
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
3
- import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
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
- * 选中判定:answer 含该 option label(answer 形如 "Postgres" / "A, B" / "X — comment")。
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 answerTokens = new Set(answer.split(/\s*[,—]\s*|\s*,\s*/).filter(Boolean));
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
- answerTokens.has(opt.label) || answer.includes(opt.label)
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: unknown,
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)→ isError
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)→ isError + 禁用工具
95
- if (!ctx.hasUI) {
96
- pi.setActiveTools(
97
- pi
98
- .getAllTools()
99
- .map((t: { name: string }) => t.name)
100
- .filter((n: string) => n !== "ask_user"),
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
- content: [
118
- {
119
- type: "text" as const,
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. 顶层 try/catchspec FR-13)
266
+ // 4. 交互执行:TUI 走 ctx.ui.custom,RPCxyz-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 = await ctx.ui.custom<Result | null>(
131
- (tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
132
- const comp = new AskUserComponent(
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
- type: "text" as const,
153
- text: `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.`,
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
- content: [
165
- {
166
- type: "text" as const,
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. 正常返回
@@ -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 + 末尾光标 █),为空则只输出 lead
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 * 0.42);
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>█(多选)/ <input>█(单选),
123
+ * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
86
124
  * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
87
125
  function buildOptionLines(
88
- q: Question,
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 = theme;
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
- const isSelected = i === state.cursorIndex;
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
- // 编号 + 文本 + 末尾光标 █ 整体软换行(空 input 时仅编号 + 光标,wrapTextWithAnsi 单行)
119
- const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", editorText)}${t.fg("accent", "█")}`;
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" : "muted";
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 - 10);
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 - 8);
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
- q: Question,
169
- state: QuestionState,
170
- theme: ThemeLike,
171
- width: number,
205
+ ctx: RenderContext,
172
206
  maxLines: number,
173
207
  ): string[] {
174
- const t = theme;
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(10, width));
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
- * 渲染单个问题视图(spec FR-4)。
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
- theme: ThemeLike,
205
- width: number,
206
- mode: "freeform" | "comment",
207
- editorText: string,
233
+ ctx: RenderContext,
208
234
  ): string[] {
209
- if (mode === "freeform") {
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
- // 渲染当前编辑器文本(单行;多行时按 \n 拆分)
221
- for (const line of editorText.split("\n")) add(` ${line}`);
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", " Enter submit · Esc back"));
250
+ add(t.fg("dim", EDITOR_HINT));
226
251
  return lines;
227
252
  }
228
253
 
229
254
  /** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
230
255
  function buildSplitPane(
231
- q: Question,
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 = theme;
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 leftLines = buildOptionLines(q, state, theme, split.left, true, editorText);
244
- const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
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
- export function renderQuestionView(
256
- q: Question,
257
- state: QuestionState,
258
- theme: ThemeLike,
259
- width: number,
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 - 2);
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 - 2);
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 optionLines = buildOptionLines(q, state, theme, width, false, editorText);
296
- for (const line of optionLines) add(line);
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", " Enter submit · Esc back"));
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 optionLines = buildOptionLines(q, state, theme, width, false, editorText);
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(q, state, theme, split, width, editorText));
328
+ lines.push(...buildSplitPane(ctx, split));
313
329
  }
314
330
 
315
331
  add("");