@zhushanwen/pi-ask-user 0.0.4 → 0.1.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/component.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  // src/component.ts
2
- import { type Component, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
3
3
 
4
4
  import { allOptions, renderQuestionView } from "./question-view";
5
- import { buildResult, renderSubmitView } from "./submit-view";
5
+ import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
6
6
  import {
7
7
  createQuestionState,
8
8
  HEADER_MAX_CHARS,
9
+ isHighSurrogate,
9
10
  type Question,
10
11
  type QuestionState,
11
12
  type Result,
13
+ SURROGATE_PAIR_LEN,
12
14
  type ThemeLike,
13
15
  } from "./types";
14
16
 
@@ -32,7 +34,6 @@ export class AskUserComponent implements Component {
32
34
 
33
35
  private states: QuestionState[];
34
36
  private activeTab: number = 0;
35
- private editorText: string = "";
36
37
 
37
38
  /** Submit tab 上的左右焦点:默认 Submit;← / → 切换;Enter 触发当前项。
38
39
  * 问题 tab 上无意义(仅视觉占位),不参与输入路由。 */
@@ -107,7 +108,7 @@ export class AskUserComponent implements Component {
107
108
  } else {
108
109
  const q = this.questions[this.activeTab]!;
109
110
  const state = this.states[this.activeTab]!;
110
- for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, this.editorText)) {
111
+ for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, state.draftText)) {
111
112
  add(line);
112
113
  }
113
114
  }
@@ -115,7 +116,7 @@ export class AskUserComponent implements Component {
115
116
  // 多问题:底部按钮栏(Submit / Cancel)。Submit tab 上不重复渲染(renderSubmitView 内嵌 focus 高亮)
116
117
  if (!this.isSingle && this.activeTab < this.questions.length) {
117
118
  inner.push("");
118
- this.renderButtonBar(innerWidth, add, null);
119
+ add(renderButtonBar(this.theme, this.allConfirmed(), null));
119
120
  }
120
121
 
121
122
  // 用 box 边框包裹:每行 pad 到 innerWidth 后加 │ 左右边框
@@ -162,28 +163,6 @@ export class AskUserComponent implements Component {
162
163
  add(truncateToWidth(parts.join(""), innerWidth));
163
164
  }
164
165
 
165
- /**
166
- * 底部按钮栏:[ Submit ] [ Cancel ]。
167
- * focus: null=纯展示(问题 tab),"submit"/"cancel"=高亮对应按钮(Submit tab)。
168
- */
169
- private renderButtonBar(
170
- innerWidth: number,
171
- add: (s: string) => void,
172
- focus: "submit" | "cancel" | null,
173
- ): void {
174
- const t = this.theme;
175
- const allDone = this.allConfirmed();
176
- const isSubmit = focus === "submit";
177
- const isCancel = focus === "cancel";
178
- const submit = isSubmit
179
- ? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
180
- : (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
181
- const cancel = isCancel
182
- ? t.fg("accent", t.bold(" Cancel "))
183
- : t.fg("muted", " Cancel ");
184
- add(`${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`);
185
- }
186
-
187
166
  // ── 输入路由 ──
188
167
  handleInput(data: string): void {
189
168
  if (this._resolved) return;
@@ -215,15 +194,22 @@ export class AskUserComponent implements Component {
215
194
  return;
216
195
  }
217
196
 
197
+ // options mode → delegate to handleOptionsInput
198
+ this.handleOptionsInput(data, state, q);
199
+ }
200
+
201
+ /**
202
+ * options 模式的输入处理(从 handleInput 拆出)。
203
+ * 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
204
+ */
205
+ private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
218
206
  // Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
219
207
  if (matchesKey(data, "escape")) {
220
208
  this.escBackOrConfirm();
221
209
  return;
222
210
  }
223
211
 
224
- // ← / → 切换问题 tab(多问题,options 模式):末尾 → 进入 Submit tab,
225
- // 首个 ← 停在首个问题(不环绕)。Tab/Shift+Tab 不在问题 tab 切 tab——
226
- // 留给 Submit tab 上的 Submit/Cancel 焦点切换。
212
+ // ← / → 切换问题 tab(多问题,options 模式)
227
213
  if (!this.isSingle && matchesKey(data, "right")) {
228
214
  this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
229
215
  return;
@@ -250,12 +236,12 @@ export class AskUserComponent implements Component {
250
236
  const opts = allOptions(q);
251
237
  const onOther = state.cursorIndex === opts.length - 1;
252
238
 
253
- // Other row → Enter opens freeform editor(单选/多选统一入口)。
254
- // 单选/多选普通选项的 Enter 各自有不同语义(确认/加入选中),与 Other 互斥:
255
- // onOther 时本分支先消费 Enter,下面多选/单选分支不再处理。
239
+ // Other row → Enter opens freeform editor
256
240
  if (onOther && matchesKey(data, "enter")) {
241
+ state.savedOptionsCursorIndex = state.cursorIndex;
257
242
  state.mode = "freeform";
258
- this.editorText = state.freeTextValue ?? "";
243
+ state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
244
+ state.cursorIndex = state.draftText.length;
259
245
  this.invalidate();
260
246
  this.tui.requestRender();
261
247
  return;
@@ -267,10 +253,6 @@ export class AskUserComponent implements Component {
267
253
  return;
268
254
  }
269
255
  if (matchesKey(data, "enter")) {
270
- // Enter 先把光标所在的普通选项加入选中再确认,与单选分支(Enter 时
271
- // selectedIndex = cursorIndex)保持一致。即使 freeTextValue 已存在
272
- // (Other 录入),也尊重「在此选项上按 Enter 想选中它」的意图,避免
273
- // 静默用旧 Other 文本确认。add 幂等,不会误取消已选项。
274
256
  state.selectedIndices.add(state.cursorIndex);
275
257
  this.afterConfirm(state, q);
276
258
  return;
@@ -330,80 +312,157 @@ export class AskUserComponent implements Component {
330
312
  }
331
313
 
332
314
  private handleEditorInput(data: string, state: QuestionState, q: Question): void {
315
+ // parseKey 白名单拦截 — 替代旧的 matchesKey 散调 + 兜底 printable 遍历
316
+ const keyId = parseKey(data);
317
+ if (keyId !== undefined) {
318
+ this.handleEditorKey(data, keyId, state, q);
319
+ return;
320
+ }
321
+ this.handleEditorPaste(data, state);
322
+ }
323
+
324
+ /** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
325
+ * 其他 special key(功能键/modifier 组合)no-op。 */
326
+ private handleEditorKey(data: string, keyId: string, state: QuestionState, q: Question): void {
333
327
  if (matchesKey(data, "escape")) {
334
- if (state.mode === "comment") {
335
- // AC-17: Esc in comment = skip comment, advance (keep existing commentValue)
336
- state.mode = "options";
337
- this.editorText = "";
338
- this.advance();
339
- return;
340
- }
341
- // Esc in freeform editor = back to options (discard input)
342
- state.mode = "options";
343
- this.editorText = "";
344
- this.invalidate();
345
- this.tui.requestRender();
328
+ this.handleEditorEsc(state);
346
329
  return;
347
330
  }
348
331
  if (matchesKey(data, "enter")) {
349
- const text = this.editorText.trim();
350
- if (state.mode === "freeform") {
351
- if (text) {
352
- state.freeTextValue = text;
353
- state.selectedIndex = null;
354
- state.mode = "options";
355
- this.editorText = "";
356
- // freeform 保存后走 afterConfirm(可能进 comment 模式)
357
- this.afterConfirm(state, q);
358
- } else {
359
- // FR-6: 空 Enter 仅清除 freeTextValue、关闭编辑器回选项列表,不含确认语义(不置 confirmed)
360
- state.freeTextValue = null;
361
- state.mode = "options";
362
- this.editorText = "";
363
- // 对齐 toggleIndex 守卫:清空后若全无答案,重置 confirmed,维持 confirmed ⟹ 有答案 不变式
364
- if ((q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) && state.freeTextValue === null) {
365
- state.confirmed = false;
366
- }
367
- this.invalidate();
368
- this.tui.requestRender();
369
- }
370
- return;
371
- }
372
- // comment mode:保存评论后直接前进(不再回头进 comment)
373
- state.commentValue = text || null;
332
+ this.handleEditorEnter(state, q);
333
+ return;
334
+ }
335
+ if (matchesKey(data, "backspace")) {
336
+ this.deleteCharBeforeCursor(state);
337
+ return;
338
+ }
339
+ if (this.moveCursor(data, state)) return;
340
+ // 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
341
+ if (matchesKey(data, "space")) {
342
+ this.insertAtCursor(state, " ");
343
+ return;
344
+ }
345
+ // 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
346
+ if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
347
+ this.insertAtCursor(state, keyId);
348
+ return;
349
+ }
350
+ // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
351
+ }
352
+
353
+ /** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
354
+ private handleEditorEsc(state: QuestionState): void {
355
+ if (state.mode === "comment") {
356
+ // comment Esc: skip comment, advance (keep existing commentValue)
374
357
  state.mode = "options";
375
- this.editorText = "";
358
+ state.draftText = "";
359
+ state.cursorIndex = state.savedOptionsCursorIndex;
376
360
  this.advance();
377
361
  return;
378
362
  }
379
- if (matchesKey(data, "backspace")) {
380
- this.editorText = this.editorText.slice(0, -1);
381
- this.invalidate();
382
- this.tui.requestRender();
363
+ // freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
364
+ // so discarded drafts don't pollute the answer or trigger auto-confirm.
365
+ state.freeDraft = state.draftText || null;
366
+ state.mode = "options";
367
+ state.draftText = "";
368
+ state.cursorIndex = state.savedOptionsCursorIndex;
369
+ this.invalidate();
370
+ this.tui.requestRender();
371
+ }
372
+
373
+ /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
374
+ private handleEditorEnter(state: QuestionState, q: Question): void {
375
+ const text = state.draftText.trim();
376
+ if (state.mode === "freeform") {
377
+ state.cursorIndex = state.savedOptionsCursorIndex;
378
+ if (text) {
379
+ state.freeTextValue = text;
380
+ state.selectedIndex = null;
381
+ state.mode = "options";
382
+ state.draftText = "";
383
+ this.afterConfirm(state, q);
384
+ } else {
385
+ state.freeTextValue = null;
386
+ state.mode = "options";
387
+ state.draftText = "";
388
+ // freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
389
+ if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
390
+ state.confirmed = false;
391
+ }
392
+ this.invalidate();
393
+ this.tui.requestRender();
394
+ }
395
+ return;
396
+ }
397
+ state.commentValue = text || null;
398
+ state.mode = "options";
399
+ state.draftText = "";
400
+ state.cursorIndex = state.savedOptionsCursorIndex;
401
+ this.advance();
402
+ }
403
+
404
+ /** 在光标处插入文本,光标前移 text.length。 */
405
+ private insertAtCursor(state: QuestionState, text: string): void {
406
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
407
+ state.cursorIndex += text.length;
408
+ this.invalidate();
409
+ this.tui.requestRender();
410
+ }
411
+
412
+ /** 删除光标前一个 code point(surrogate pair 时删整个 code point)。 */
413
+ private deleteCharBeforeCursor(state: QuestionState): void {
414
+ if (state.cursorIndex <= 0) return;
415
+ const deleteCount = state.cursorIndex >= SURROGATE_PAIR_LEN && isHighSurrogate(state.draftText, state.cursorIndex - SURROGATE_PAIR_LEN) ? SURROGATE_PAIR_LEN : 1;
416
+ state.draftText = state.draftText.slice(0, state.cursorIndex - deleteCount) + state.draftText.slice(state.cursorIndex);
417
+ state.cursorIndex -= deleteCount;
418
+ this.invalidate();
419
+ this.tui.requestRender();
420
+ }
421
+
422
+ /** 光标移动(left/right/home/end),surrogate pair 安全跳过代理中间位。
423
+ * 返回 true 表示命中了移动键;false 表示非移动键,交由上层处理。 */
424
+ private moveCursor(data: string, state: QuestionState): boolean {
425
+ if (matchesKey(data, "left")) {
426
+ const newLeft = state.cursorIndex - 1;
427
+ state.cursorIndex = newLeft > 0 && isHighSurrogate(state.draftText, newLeft - 1)
428
+ ? newLeft - 1
429
+ : Math.max(0, newLeft);
430
+ } else if (matchesKey(data, "right")) {
431
+ state.cursorIndex = isHighSurrogate(state.draftText, state.cursorIndex)
432
+ ? Math.min(state.draftText.length, state.cursorIndex + SURROGATE_PAIR_LEN)
433
+ : Math.min(state.draftText.length, state.cursorIndex + 1);
434
+ } else if (matchesKey(data, "home")) {
435
+ state.cursorIndex = 0;
436
+ } else if (matchesKey(data, "end")) {
437
+ state.cursorIndex = state.draftText.length;
438
+ } else {
439
+ return false;
440
+ }
441
+ this.invalidate();
442
+ this.tui.requestRender();
443
+ return true;
444
+ }
445
+
446
+ /** 多字符粘贴 chunk → printable 提取(BC-1/BC-2/BC-3 保持)。
447
+ * 未识别控制序列(OSC/DA/DCS/APC/unknown CSI)整体丢弃,不提取可见残渣。
448
+ * 排除 bracketed paste 标记防 C-PASTE 退化。依赖 StdinBuffer 序列拆分保证 data 整体性。 */
449
+ private handleEditorPaste(data: string, state: QuestionState): void {
450
+ if (data.startsWith("\x1b") && !data.includes("\x1b[200~") && !data.includes("\x1b[201~")) {
383
451
  return;
384
452
  }
385
- // Printable char(s) — handle both single keystrokes and multi-char paste.
386
- // Terminals deliver pasted text as a single data chunk; iterating each char
387
- // ensures the full paste is captured instead of silently dropping everything
388
- // after the first character.
389
- // 先剥离 bracketed paste 标记序列:启用该模式的终端会把粘贴内容包裹在
390
- // \x1b[200~ ... \x1b[201~ 中。下面的 `c >= " "` 守卫会滤掉 ESC(\x1b),
391
- // 但序列里的可见字符([200~/[201~)会残留混进编辑器文本,必须显式剥离。
392
453
  const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
393
- // for...of 按 code point 迭代:代理对(如 😀 U+1F600)作为一个 c(length===2)出现,
394
- // 因此不能用 `c.length === 1` 守卫——那会把所有 BMP 之外的字符(emoji、部分 CJK 扩展)全过滤。
395
- // 只保留 `c >= " "`(code point 比较),过滤控制字符(< 空格 U+0020)。
396
454
  let changed = false;
397
- for (const c of cleaned) {
455
+ // 用 Array.from 正确拆分 emoji/surrogate pairs
456
+ for (const c of Array.from(cleaned)) {
398
457
  if (c >= " ") {
399
- this.editorText += c;
458
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
459
+ state.cursorIndex += c.length;
400
460
  changed = true;
401
461
  }
402
462
  }
403
463
  if (changed) {
404
464
  this.invalidate();
405
465
  this.tui.requestRender();
406
- return;
407
466
  }
408
467
  }
409
468
 
@@ -452,10 +511,10 @@ export class AskUserComponent implements Component {
452
511
  private afterConfirm(state: QuestionState, q: Question): void {
453
512
  state.confirmed = true;
454
513
  if (q.allowComment && state.mode !== "comment") {
455
- // 进入评论输入行。预填已有评论,空 Enter=清除、新文本=覆盖(FR-4 item6)。
456
- // 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
514
+ state.savedOptionsCursorIndex = state.cursorIndex;
457
515
  state.mode = "comment";
458
- this.editorText = state.commentValue ?? "";
516
+ state.draftText = state.commentValue ?? "";
517
+ state.cursorIndex = state.draftText.length;
459
518
  this.invalidate();
460
519
  this.tui.requestRender();
461
520
  return;
@@ -482,8 +541,11 @@ export class AskUserComponent implements Component {
482
541
  this.done(buildResult(this.questions, this.states));
483
542
  }
484
543
 
485
- /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态) */
544
+ /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态)。
545
+ * 守卫在方法内:signal abort 可能在用户已 submit/cancel 后才触发,
546
+ * 此时必须 no-op,避免二次调 done(FR-12)。 */
486
547
  cancel(): void {
548
+ if (this._resolved) return;
487
549
  this._resolved = true;
488
550
  this.done(null);
489
551
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
2
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
3
3
  import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
4
4
  import { type Static } from "@sinclair/typebox";
5
5
 
@@ -76,7 +76,7 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
76
76
  _toolCallId: string,
77
77
  params: Static<typeof InputSchema>,
78
78
  signal: AbortSignal | undefined,
79
- _onUpdate: unknown,
79
+ _onUpdate: AgentToolUpdateCallback<AskUserDetails> | undefined,
80
80
  ctx: ExtensionContext,
81
81
  ): Promise<ExecuteResult> {
82
82
  const questions = params.questions;
@@ -111,13 +111,15 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
111
111
  };
112
112
  }
113
113
 
114
- // 3. Signal abort 入口检查(spec FR-10)
114
+ // 3. Signal abort 入口检查(spec FR-10)。
115
+ // abort 是 agent 被外部终止(goal 取消 / context compact / session 切换),
116
+ // 不是用户意图——文案必须区别于 step 5 的用户取消,避免 LLM 俊等用户。
115
117
  if (signal?.aborted) {
116
118
  return {
117
119
  content: [
118
120
  {
119
121
  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.",
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.",
121
123
  },
122
124
  ],
123
125
  details: { questions, answers: {}, cancelled: true } satisfies Result,
@@ -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,9 +10,16 @@ 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;
@@ -21,6 +29,21 @@ export interface DisplayOption {
21
29
  /** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
22
30
  const MAX_EDITOR_LINES = 5;
23
31
 
32
+ /**
33
+ * 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
34
+ * surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
35
+ * 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
36
+ * 光标在文本末尾(超出范围)时反色高亮一个空格占位。
37
+ */
38
+ function renderCursorText(text: string, cursorPos: number): string {
39
+ const before = text.slice(0, cursorPos);
40
+ // 光标在高代理前 → 反色高亮整个 surrogate pair
41
+ const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
42
+ const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
43
+ const after = text.slice(cursorPos + charLen);
44
+ return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
45
+ }
46
+
24
47
  /**
25
48
  * 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
26
49
  * - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
@@ -29,7 +52,7 @@ const MAX_EDITOR_LINES = 5;
29
52
  *
30
53
  * @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
31
54
  * @param lead 首行前缀(含选中标记 / 勾选框)
32
- * @param content 待换行展示的已样式化文本(可含 ANSI + 末尾光标 █),为空则只输出 lead
55
+ * @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
33
56
  * @param availWidth 单行可用宽度
34
57
  * @param maxLines 最多行数
35
58
  */
@@ -71,7 +94,7 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
71
94
  if (width < SPLIT_PANE_MIN_WIDTH) return null;
72
95
  const available = width - SPLIT_PANE_SEPARATOR.length;
73
96
  if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
74
- const preferredLeft = Math.floor(available * 0.42);
97
+ const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
75
98
  const left = Math.max(
76
99
  SPLIT_PANE_LEFT_MIN,
77
100
  Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
@@ -81,8 +104,12 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
81
104
  return { left, right };
82
105
  }
83
106
 
107
+ /** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
108
+ * 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
109
+ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
110
+
84
111
  /** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
85
- * freeform 模式下,Other 行**原地**变 [ ] <input>█(多选)/ <input>█(单选),
112
+ * freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
86
113
  * 不再依赖 buildEditorBlock 的下方独立编辑块。 */
87
114
  function buildOptionLines(
88
115
  q: Question,
@@ -90,7 +117,7 @@ function buildOptionLines(
90
117
  theme: ThemeLike,
91
118
  width: number,
92
119
  hideDescriptions: boolean,
93
- editorText: string = "",
120
+ draftText: string = "",
94
121
  ): string[] {
95
122
  const t = theme;
96
123
  const opts = allOptions(q);
@@ -101,7 +128,9 @@ function buildOptionLines(
101
128
 
102
129
  for (let i = 0; i < opts.length; i++) {
103
130
  const opt = opts[i]!;
104
- const isSelected = i === state.cursorIndex;
131
+ // 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
132
+ const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
133
+ const isSelected = i === activeOptionCursor;
105
134
  const isOther = opt.isOther === true;
106
135
  const prefix = isSelected ? t.fg("accent", ">") : " ";
107
136
 
@@ -115,15 +144,16 @@ function buildOptionLines(
115
144
  const num = i + 1;
116
145
  const lead = `${prefix} ${marker} `;
117
146
  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", "█")}`;
147
+ // 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
148
+ const cursorText = renderCursorText(draftText, state.cursorIndex);
149
+ const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
120
150
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
121
151
  } else {
122
152
  const hasFreeText = state.freeTextValue !== null;
123
153
  const marker = q.multiSelect
124
154
  ? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
125
155
  : (hasFreeText ? t.fg("success", "✓") : " ");
126
- const labelColor = isSelected ? "accent" : "muted";
156
+ const labelColor = isSelected ? "accent" : "text";
127
157
  const num = i + 1;
128
158
  add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
129
159
  if (hasFreeText) {
@@ -145,7 +175,7 @@ function buildOptionLines(
145
175
  const num = i + 1;
146
176
  add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
147
177
  if (opt.description && !hideDescriptions) {
148
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 10);
178
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
149
179
  for (const line of wrapped) add(` ${line}`);
150
180
  }
151
181
  } else {
@@ -155,7 +185,7 @@ function buildOptionLines(
155
185
  const num = i + 1;
156
186
  add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
157
187
  if (opt.description && !hideDescriptions) {
158
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 8);
188
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
159
189
  for (const line of wrapped) add(` ${line}`);
160
190
  }
161
191
  }
@@ -184,19 +214,14 @@ function buildPreviewLines(
184
214
  if (opt.description?.trim()) text += `\n\n${opt.description}`;
185
215
  }
186
216
 
187
- const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(10, width));
217
+ const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
188
218
  const lines = wrapped.slice(0, maxLines);
189
219
  if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
190
220
  return lines;
191
221
  }
192
222
 
193
223
  /**
194
- * 渲染单个问题视图(spec FR-4)。
195
- * isSingle: 单问题模式(无 Tab 提示)。
196
- * editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
197
- */
198
- /**
199
- * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
224
+ * freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
200
225
  * buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
201
226
  * comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
202
227
  */
@@ -204,7 +229,8 @@ function buildEditorBlock(
204
229
  theme: ThemeLike,
205
230
  width: number,
206
231
  mode: "freeform" | "comment",
207
- editorText: string,
232
+ draftText: string,
233
+ cursorIndex?: number,
208
234
  ): string[] {
209
235
  if (mode === "freeform") {
210
236
  return [""];
@@ -217,12 +243,12 @@ 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 pos = cursorIndex ?? draftText.length;
248
+ const cursorText = renderCursorText(draftText, pos);
249
+ add(` ${t.fg("text", cursorText)}`);
224
250
  add("");
225
- add(t.fg("dim", " Enter submit · Esc back"));
251
+ add(t.fg("dim", EDITOR_HINT));
226
252
  return lines;
227
253
  }
228
254
 
@@ -233,14 +259,14 @@ function buildSplitPane(
233
259
  theme: ThemeLike,
234
260
  split: { left: number; right: number },
235
261
  width: number,
236
- editorText: string = "",
262
+ draftText: string = "",
237
263
  ): string[] {
238
264
  const t = theme;
239
265
  const lines: string[] = [];
240
266
  const add = (s: string): void => {
241
267
  lines.push(truncateToWidth(s, width));
242
268
  };
243
- const leftLines = buildOptionLines(q, state, theme, split.left, true, editorText);
269
+ const leftLines = buildOptionLines(q, state, theme, split.left, true, draftText);
244
270
  const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
245
271
  const rowCount = Math.max(leftLines.length, rightLines.length);
246
272
  const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
@@ -252,13 +278,18 @@ function buildSplitPane(
252
278
  return lines;
253
279
  }
254
280
 
281
+ /**
282
+ * 渲染单个问题视图(spec FR-4)。
283
+ * isSingle: 单问题模式(无 Tab 提示)。
284
+ * draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
285
+ */
255
286
  export function renderQuestionView(
256
287
  q: Question,
257
288
  state: QuestionState,
258
289
  theme: ThemeLike,
259
290
  width: number,
260
291
  isSingle: boolean,
261
- editorText: string,
292
+ draftText: string,
262
293
  ): string[] {
263
294
  const t = theme;
264
295
  const lines: string[] = [];
@@ -268,13 +299,13 @@ export function renderQuestionView(
268
299
  const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
269
300
 
270
301
  // 问题文本(word-wrap)
271
- const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - 2);
302
+ const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
272
303
  for (const line of wrapped) add(line);
273
304
 
274
305
  // 上下文(如有)
275
306
  if (q.context?.trim()) {
276
307
  divider();
277
- const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - 2);
308
+ const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
278
309
  for (const line of ctxWrapped) add(line);
279
310
  }
280
311
 
@@ -292,24 +323,24 @@ export function renderQuestionView(
292
323
  // 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
293
324
  if (state.mode === "freeform" || state.mode === "comment") {
294
325
  add("");
295
- const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
326
+ const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
296
327
  for (const line of optionLines) add(line);
297
- const editorBlock = buildEditorBlock(theme, width, state.mode, editorText);
328
+ const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
298
329
  lines.push(...editorBlock);
299
330
  if (state.mode === "freeform") {
300
331
  // freeform 模式 help 行:光标锁在 Other 上,正在输入
301
- add(t.fg("dim", " Enter submit · Esc back"));
332
+ add(t.fg("dim", EDITOR_HINT));
302
333
  }
303
334
  return lines;
304
335
  }
305
336
 
306
337
  if (!split) {
307
338
  // 单列模式
308
- const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
339
+ const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
309
340
  for (const line of optionLines) add(line);
310
341
  } else {
311
342
  // 分屏模式
312
- lines.push(...buildSplitPane(q, state, theme, split, width, editorText));
343
+ lines.push(...buildSplitPane(q, state, theme, split, width, draftText));
313
344
  }
314
345
 
315
346
  add("");