@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.
@@ -0,0 +1,51 @@
1
+ // src/answer-format.ts
2
+ // 答案文本格式的唯一权威模块。
3
+ // TUI 路径(submit-view.ts:getAnswerText)和 RPC 路径(index.ts:protoAnswersToResult)
4
+ // 都调 formatAnswer 产出 "label1, label2 — comment" 格式,确保两条路径一致。
5
+ // renderExpandedOptions(index.ts)调 parseAnswerParts 精确反解析选中项。
6
+ import { ANSWER_COMMENT_SEPARATOR } from "./types";
7
+
8
+ /**
9
+ * 把答案各部分拼装为最终文本格式:"part1, part2 — comment"。
10
+ * - parts 为空 → 返回 null(未答)
11
+ * - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
12
+ */
13
+ export function formatAnswer(parts: string[], comment?: string | null): string | null {
14
+ if (parts.length === 0) return null;
15
+ const base = parts.join(", ");
16
+ return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
17
+ }
18
+
19
+ /**
20
+ * 从最终答案文本中精确解析出选中的 labels(不依赖子串匹配)。
21
+ * 用于 renderExpandedOptions 反向判定哪些选项被选中。
22
+ *
23
+ * @param answer 最终答案文本(formatAnswer 产出)
24
+ * @param labels 候选 label 列表(q.options 的 label),精确匹配
25
+ * @returns selected=命中的 labels(按 answer 中出现顺序),comment=评论文本(如有)
26
+ */
27
+ export function parseAnswerParts(
28
+ answer: string,
29
+ labels: string[],
30
+ ): { selected: string[]; comment?: string } {
31
+ // 先提取 comment(ANSWER_COMMENT_SEPARATOR 之后的部分)
32
+ let body = answer;
33
+ let comment: string | undefined;
34
+ const sepIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
35
+ if (sepIdx >= 0) {
36
+ body = answer.slice(0, sepIdx);
37
+ comment = answer.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim();
38
+ }
39
+
40
+ // body 形如 "label1, label2" → 精确匹配候选 label
41
+ const labelSet = new Set(labels);
42
+ const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
43
+ const selected: string[] = [];
44
+ // 剩余 tokens 不匹配任何 label → 是 Other 自由文本(不返回,调用方自行处理)
45
+ for (const token of tokens) {
46
+ if (labelSet.has(token)) {
47
+ selected.push(token);
48
+ }
49
+ }
50
+ return { selected, comment };
51
+ }
package/src/component.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  // src/component.ts
2
2
  import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
3
3
 
4
+ import {
5
+ deleteCharBeforeCursor,
6
+ handleEditorPaste,
7
+ insertAtCursor,
8
+ moveCursorEnd,
9
+ moveCursorHome,
10
+ moveCursorLeft,
11
+ moveCursorRight,
12
+ } from "./editor-ops";
4
13
  import { allOptions, renderQuestionView } from "./question-view";
5
14
  import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
6
15
  import {
7
16
  createQuestionState,
8
17
  HEADER_MAX_CHARS,
9
- isHighSurrogate,
10
18
  type Question,
11
19
  type QuestionState,
12
20
  type Result,
13
- SURROGATE_PAIR_LEN,
14
21
  type ThemeLike,
15
22
  } from "./types";
16
23
 
@@ -76,6 +83,13 @@ export class AskUserComponent implements Component {
76
83
  this.cachedLines = undefined;
77
84
  }
78
85
 
86
+ /** 状态变更后的标准后续:失效缓存 + 请求重绘。
87
+ * 消除散弹式修改——每个 mutation 方法末尾不再需要手写 invalidate + requestRender 两行。 */
88
+ private rerender(): void {
89
+ this.invalidate();
90
+ this.tui.requestRender();
91
+ }
92
+
79
93
  // ── 渲染 ──
80
94
  render(width: number): string[] {
81
95
  if (this.cachedWidth === width && this.cachedLines) {
@@ -108,7 +122,7 @@ export class AskUserComponent implements Component {
108
122
  } else {
109
123
  const q = this.questions[this.activeTab]!;
110
124
  const state = this.states[this.activeTab]!;
111
- for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, state.draftText)) {
125
+ for (const line of renderQuestionView({ question: q, state, theme: t, width: innerWidth, isSingle: this.isSingle })) {
112
126
  add(line);
113
127
  }
114
128
  }
@@ -173,8 +187,7 @@ export class AskUserComponent implements Component {
173
187
  this.cancel();
174
188
  } else {
175
189
  this.pendingCancel = false;
176
- this.invalidate();
177
- this.tui.requestRender();
190
+ this.rerender();
178
191
  }
179
192
  return;
180
193
  }
@@ -221,15 +234,13 @@ export class AskUserComponent implements Component {
221
234
 
222
235
  if (matchesKey(data, "up")) {
223
236
  state.cursorIndex = Math.max(0, state.cursorIndex - 1);
224
- this.invalidate();
225
- this.tui.requestRender();
237
+ this.rerender();
226
238
  return;
227
239
  }
228
240
  if (matchesKey(data, "down")) {
229
241
  const max = allOptions(q).length - 1;
230
242
  state.cursorIndex = Math.min(max, state.cursorIndex + 1);
231
- this.invalidate();
232
- this.tui.requestRender();
243
+ this.rerender();
233
244
  return;
234
245
  }
235
246
 
@@ -242,8 +253,7 @@ export class AskUserComponent implements Component {
242
253
  state.mode = "freeform";
243
254
  state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
244
255
  state.cursorIndex = state.draftText.length;
245
- this.invalidate();
246
- this.tui.requestRender();
256
+ this.rerender();
247
257
  return;
248
258
  }
249
259
 
@@ -288,15 +298,13 @@ export class AskUserComponent implements Component {
288
298
  // Esc → 回退到最后一个问题
289
299
  if (matchesKey(data, "escape")) {
290
300
  this.activeTab = this.questions.length - 1;
291
- this.invalidate();
292
- this.tui.requestRender();
301
+ this.rerender();
293
302
  return;
294
303
  }
295
304
  // Tab → Submit ↔ Cancel 循环切焦点(单键双向)
296
305
  if (matchesKey(data, "tab")) {
297
306
  this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
298
- this.invalidate();
299
- this.tui.requestRender();
307
+ this.rerender();
300
308
  return;
301
309
  }
302
310
  // Enter → 触发当前 focus
@@ -318,7 +326,7 @@ export class AskUserComponent implements Component {
318
326
  this.handleEditorKey(data, keyId, state, q);
319
327
  return;
320
328
  }
321
- this.handleEditorPaste(data, state);
329
+ if (handleEditorPaste(state, data)) this.rerender();
322
330
  }
323
331
 
324
332
  /** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
@@ -333,18 +341,24 @@ export class AskUserComponent implements Component {
333
341
  return;
334
342
  }
335
343
  if (matchesKey(data, "backspace")) {
336
- this.deleteCharBeforeCursor(state);
344
+ if (deleteCharBeforeCursor(state)) this.rerender();
337
345
  return;
338
346
  }
339
- if (this.moveCursor(data, state)) return;
347
+ // 光标移动(4 方向 + home/end)
348
+ if (matchesKey(data, "left")) { moveCursorLeft(state); this.rerender(); return; }
349
+ if (matchesKey(data, "right")) { moveCursorRight(state); this.rerender(); return; }
350
+ if (matchesKey(data, "home")) { moveCursorHome(state); this.rerender(); return; }
351
+ if (matchesKey(data, "end")) { moveCursorEnd(state); this.rerender(); return; }
340
352
  // 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
341
353
  if (matchesKey(data, "space")) {
342
- this.insertAtCursor(state, " ");
354
+ insertAtCursor(state, " ");
355
+ this.rerender();
343
356
  return;
344
357
  }
345
358
  // 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
346
359
  if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
347
- this.insertAtCursor(state, keyId);
360
+ insertAtCursor(state, keyId);
361
+ this.rerender();
348
362
  return;
349
363
  }
350
364
  // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
@@ -366,8 +380,7 @@ export class AskUserComponent implements Component {
366
380
  state.mode = "options";
367
381
  state.draftText = "";
368
382
  state.cursorIndex = state.savedOptionsCursorIndex;
369
- this.invalidate();
370
- this.tui.requestRender();
383
+ this.rerender();
371
384
  }
372
385
 
373
386
  /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
@@ -389,8 +402,7 @@ export class AskUserComponent implements Component {
389
402
  if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
390
403
  state.confirmed = false;
391
404
  }
392
- this.invalidate();
393
- this.tui.requestRender();
405
+ this.rerender();
394
406
  }
395
407
  return;
396
408
  }
@@ -401,79 +413,13 @@ export class AskUserComponent implements Component {
401
413
  this.advance();
402
414
  }
403
415
 
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~")) {
451
- return;
452
- }
453
- const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
454
- let changed = false;
455
- // 用 Array.from 正确拆分 emoji/surrogate pairs
456
- for (const c of Array.from(cleaned)) {
457
- if (c >= " ") {
458
- state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
459
- state.cursorIndex += c.length;
460
- changed = true;
461
- }
462
- }
463
- if (changed) {
464
- this.invalidate();
465
- this.tui.requestRender();
466
- }
467
- }
468
-
469
416
  private toggleIndex(state: QuestionState, index: number): void {
470
417
  if (state.selectedIndices.has(index)) state.selectedIndices.delete(index);
471
418
  else state.selectedIndices.add(index);
472
419
  if (state.selectedIndices.size === 0 && state.freeTextValue === null) {
473
420
  state.confirmed = false;
474
421
  }
475
- this.invalidate();
476
- this.tui.requestRender();
422
+ this.rerender();
477
423
  }
478
424
 
479
425
  private autoConfirmIfAnswered(): void {
@@ -490,21 +436,18 @@ export class AskUserComponent implements Component {
490
436
  private gotoTab(target: number): void {
491
437
  this.autoConfirmIfAnswered();
492
438
  this.activeTab = target;
493
- this.invalidate();
494
- this.tui.requestRender();
439
+ this.rerender();
495
440
  }
496
441
 
497
442
  /** Esc 语义:有上一个 tab 则回退;已在首个(或单问题)则进入确认取消覆盖层。 */
498
443
  private escBackOrConfirm(): void {
499
444
  if (this.activeTab > 0) {
500
445
  this.activeTab--;
501
- this.invalidate();
502
- this.tui.requestRender();
446
+ this.rerender();
503
447
  return;
504
448
  }
505
449
  this.pendingCancel = true;
506
- this.invalidate();
507
- this.tui.requestRender();
450
+ this.rerender();
508
451
  }
509
452
 
510
453
  /** 选中确认后的处理:若 allowComment,进入评论模式(可重入编辑/清除已有评论);否则前进。 */
@@ -515,8 +458,7 @@ export class AskUserComponent implements Component {
515
458
  state.mode = "comment";
516
459
  state.draftText = state.commentValue ?? "";
517
460
  state.cursorIndex = state.draftText.length;
518
- this.invalidate();
519
- this.tui.requestRender();
461
+ this.rerender();
520
462
  return;
521
463
  }
522
464
  this.advance();
@@ -532,8 +474,7 @@ export class AskUserComponent implements Component {
532
474
  } else {
533
475
  this.activeTab = this.questions.length;
534
476
  }
535
- this.invalidate();
536
- this.tui.requestRender();
477
+ this.rerender();
537
478
  }
538
479
 
539
480
  private submit(): void {
@@ -0,0 +1,75 @@
1
+ // src/editor-ops.ts
2
+ // 文本编辑器的纯操作函数——操作 QuestionState.draftText/cursorIndex。
3
+ // 从 AskUserComponent 提取,使 component 聚焦于问卷交互(tab 导航/状态转换/渲染编排),
4
+ // 不再理解终端转义序列解析和 surrogate pair 细节。
5
+ //
6
+ // 所有函数直接修改传入的 state(mutation 风格,与调用方 component 的命令式风格一致)。
7
+ // 是否产生了变更由返回值表达,调用方据此决定是否 rerender。
8
+
9
+ import { isHighSurrogate, SURROGATE_PAIR_LEN, type QuestionState } from "./types";
10
+
11
+ // ── 编辑器纯操作 ──
12
+
13
+ // ── 编辑器纯操作 ──
14
+
15
+ /** 在光标处插入文本,光标前移 text.length。 */
16
+ export function insertAtCursor(state: QuestionState, text: string): void {
17
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
18
+ state.cursorIndex += text.length;
19
+ }
20
+
21
+ /** 删除光标前一个 code point(surrogate pair 时删整个 code point)。
22
+ * 返回 true 表示有删除发生(调用方据此决定是否 invalidate)。 */
23
+ export function deleteCharBeforeCursor(state: QuestionState): boolean {
24
+ if (state.cursorIndex <= 0) return false;
25
+ const deleteCount = state.cursorIndex >= SURROGATE_PAIR_LEN && isHighSurrogate(state.draftText, state.cursorIndex - SURROGATE_PAIR_LEN) ? SURROGATE_PAIR_LEN : 1;
26
+ state.draftText = state.draftText.slice(0, state.cursorIndex - deleteCount) + state.draftText.slice(state.cursorIndex);
27
+ state.cursorIndex -= deleteCount;
28
+ return true;
29
+ }
30
+
31
+ /** 光标左移一个 code point(surrogate pair 安全)。不超出 0。 */
32
+ export function moveCursorLeft(state: QuestionState): void {
33
+ const newLeft = state.cursorIndex - 1;
34
+ state.cursorIndex = newLeft > 0 && isHighSurrogate(state.draftText, newLeft - 1)
35
+ ? newLeft - 1
36
+ : Math.max(0, newLeft);
37
+ }
38
+
39
+ /** 光标右移一个 code point(surrogate pair 安全)。不超出 draftText.length。 */
40
+ export function moveCursorRight(state: QuestionState): void {
41
+ state.cursorIndex = isHighSurrogate(state.draftText, state.cursorIndex)
42
+ ? Math.min(state.draftText.length, state.cursorIndex + SURROGATE_PAIR_LEN)
43
+ : Math.min(state.draftText.length, state.cursorIndex + 1);
44
+ }
45
+
46
+ /** 光标移到行首。 */
47
+ export function moveCursorHome(state: QuestionState): void {
48
+ state.cursorIndex = 0;
49
+ }
50
+
51
+ /** 光标移到行尾。 */
52
+ export function moveCursorEnd(state: QuestionState): void {
53
+ state.cursorIndex = state.draftText.length;
54
+ }
55
+
56
+ /** 多字符粘贴 chunk → printable 提取。
57
+ * 排除 bracketed paste 标记,过滤未识别控制序列(OSC/DA/DCS/APC/unknown CSI)。
58
+ * 返回 true 表示有文本插入。
59
+ * data 整体性由 StdinBuffer 序列拆分保证。 */
60
+ export function handleEditorPaste(state: QuestionState, data: string): boolean {
61
+ if (data.startsWith("\x1b") && !data.includes("\x1b[200~") && !data.includes("\x1b[201~")) {
62
+ return false;
63
+ }
64
+ const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
65
+ let changed = false;
66
+ // 用 Array.from 正确拆分 emoji/surrogate pairs
67
+ for (const c of Array.from(cleaned)) {
68
+ if (c >= " ") {
69
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
70
+ state.cursorIndex += c.length;
71
+ changed = true;
72
+ }
73
+ }
74
+ return changed;
75
+ }