@zhushanwen/pi-ask-user 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor, and optional comments.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -437,6 +437,39 @@ describe("AskUserComponent — multi-char paste in editor", () => {
437
437
  expect(editorLine).toBeDefined();
438
438
  expect(editorLine).toContain("x");
439
439
  });
440
+
441
+ it("C-PASTE-6: bracketed paste 序列被剥离,不残留 [200~/[201~", () => {
442
+ // 回归:启用 bracketed paste mode 的终端粘贴时会把内容包裹在
443
+ // \x1b[200~ ... \x1b[201~ 中。ESC 被守卫过滤,但 [200~/[201~ 可见字符
444
+ // 会残留。修复:handleEditorInput 先 replace 剥离这两个序列。
445
+ const { c } = make([singleQ]);
446
+ c.handleInput(DOWN);
447
+ c.handleInput(DOWN);
448
+ c.handleInput(ENTER); // 打开 freeform
449
+ c.handleInput("\x1b[200~hello\x1b[201~");
450
+ const lines = c.render(60);
451
+ const editorLine = lines.find((l) => l.includes("█"));
452
+ expect(editorLine).toBeDefined();
453
+ expect(editorLine).toContain("hello");
454
+ expect(editorLine).not.toContain("[200~");
455
+ expect(editorLine).not.toContain("[201~");
456
+ });
457
+
458
+ it("C-PASTE-7: bracketed paste 跨 chunk 抵达时每个 chunk 独立剥离", () => {
459
+ // 边界:粘贴内容分多个 data chunk 抵达,每个 chunk 各自带始/末标记。
460
+ // 简单 replace 在此场景仍有效(每个 chunk 独立剥离自己的标记)。
461
+ const { c } = make([singleQ]);
462
+ c.handleInput(DOWN);
463
+ c.handleInput(DOWN);
464
+ c.handleInput(ENTER);
465
+ c.handleInput("\x1b[200~foo ");
466
+ c.handleInput(" bar\x1b[201~");
467
+ const lines = c.render(60);
468
+ const editorLine = lines.find((l) => l.includes("█"));
469
+ expect(editorLine).toContain("foo bar");
470
+ expect(editorLine).not.toContain("[200~");
471
+ expect(editorLine).not.toContain("[201~");
472
+ });
440
473
  });
441
474
 
442
475
  // ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
@@ -374,6 +374,99 @@ describe("renderQuestionView — help line", () => {
374
374
  });
375
375
  });
376
376
 
377
+ // ── Q-32 ~ Q-35: Other 选项对齐(单选/多选 freeform、非 freeform、预览) ──
378
+ describe("renderQuestionView — Other row alignment", () => {
379
+ it("Q-32: 单选 Other freeform 编辑行编号与普通选项对齐", () => {
380
+ // 回归:单选 freeform 占位从 \" \"(2列) 改为 \" \"(1列),编号列与普通单选对齐
381
+ const lines = renderQuestionView(
382
+ singleQ,
383
+ makeState({ mode: "freeform", cursorIndex: 2 }),
384
+ stubTheme,
385
+ 60,
386
+ true,
387
+ "custom",
388
+ );
389
+ const normalLine = lines.find((l) => l.includes("Postgres"))!;
390
+ const otherLine = lines.find((l) => l.includes("█"))!;
391
+ // stubTheme 无 ANSI,indexOf 反映可见列位置。普通选项编号 idx === Other 编号 idx
392
+ expect(normalLine.indexOf("1.")).toBe(otherLine.indexOf("3."));
393
+ });
394
+
395
+ it("Q-33: 多选 Other(无 freeText)编号与普通选项对齐", () => {
396
+ // 回归:多选 Other 非 freeform 标记从 check(1列) 改为 box(3列),编号列与普通多选对齐
397
+ const multiQ: Question = {
398
+ question: "Which features?",
399
+ multiSelect: true,
400
+ options: [{ label: "Auth" }, { label: "Search" }],
401
+ };
402
+ const lines = renderQuestionView(
403
+ multiQ,
404
+ makeState({ cursorIndex: 2 }),
405
+ stubTheme,
406
+ 60,
407
+ true,
408
+ "",
409
+ );
410
+ const normalLine = lines.find((l) => l.includes("Auth"))!;
411
+ const otherLine = lines.find((l) => l.includes("Other") && l.includes("3."))!;
412
+ expect(normalLine.indexOf("1.")).toBe(otherLine.indexOf("3."));
413
+ });
414
+
415
+ it("Q-34: 单选 Other freeText 预览缩进对齐到 label 起始列", () => {
416
+ // 回归:预览 lead 从硬编码 6 列改为动态计算(单选个位 = 7 列)
417
+ const lines = renderQuestionView(
418
+ singleQ,
419
+ makeState({ cursorIndex: 2, freeTextValue: "saved" }),
420
+ stubTheme,
421
+ 60,
422
+ true,
423
+ "",
424
+ );
425
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
426
+ const previewLine = lines.find((l) => l.includes('"saved"'))!;
427
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
428
+ });
429
+
430
+ it("Q-35: 多选 Other freeText 预览缩进对齐到 label 起始列", () => {
431
+ // 回归:多选 marker=3列,预览 lead 应 = 9 列(个位编号),与 label 对齐
432
+ const multiQ: Question = {
433
+ question: "Which features?",
434
+ multiSelect: true,
435
+ options: [{ label: "Auth" }, { label: "Search" }],
436
+ };
437
+ const lines = renderQuestionView(
438
+ multiQ,
439
+ makeState({ cursorIndex: 2, freeTextValue: "saved" }),
440
+ stubTheme,
441
+ 60,
442
+ true,
443
+ "",
444
+ );
445
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
446
+ const previewLine = lines.find((l) => l.includes('"saved"'))!;
447
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
448
+ });
449
+
450
+ it("Q-35b: 两位数编号时预览缩进随编号宽度自适应", () => {
451
+ // 10 个选项 + Other = 第 11 项,编号 \"11.\" 占 3 列,预览 lead 应比个位多 1 列
452
+ const bigQ: Question = {
453
+ question: "Q",
454
+ options: Array.from({ length: 10 }, (_, k) => ({ label: `Opt${k + 1}` })),
455
+ };
456
+ const lines = renderQuestionView(
457
+ bigQ,
458
+ makeState({ cursorIndex: 10, freeTextValue: "x" }),
459
+ stubTheme,
460
+ 60,
461
+ true,
462
+ "",
463
+ );
464
+ const labelLine = lines.find((l) => l.includes("Other") && !l.includes('"'))!;
465
+ const previewLine = lines.find((l) => l.includes('"x"'))!;
466
+ expect(labelLine.indexOf("Other")).toBe(previewLine.indexOf('"'));
467
+ });
468
+ });
469
+
377
470
  // ── Q-24 ~ Q-25: 上下文 ─────────────────────────────────
378
471
  describe("renderQuestionView — context", () => {
379
472
  it("Q-24: renders context when present", () => {
package/src/component.ts CHANGED
@@ -386,11 +386,15 @@ export class AskUserComponent implements Component {
386
386
  // Terminals deliver pasted text as a single data chunk; iterating each char
387
387
  // ensures the full paste is captured instead of silently dropping everything
388
388
  // after the first character.
389
+ // 先剥离 bracketed paste 标记序列:启用该模式的终端会把粘贴内容包裹在
390
+ // \x1b[200~ ... \x1b[201~ 中。下面的 `c >= " "` 守卫会滤掉 ESC(\x1b),
391
+ // 但序列里的可见字符([200~/[201~)会残留混进编辑器文本,必须显式剥离。
392
+ const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
389
393
  // for...of 按 code point 迭代:代理对(如 😀 U+1F600)作为一个 c(length===2)出现,
390
394
  // 因此不能用 `c.length === 1` 守卫——那会把所有 BMP 之外的字符(emoji、部分 CJK 扩展)全过滤。
391
395
  // 只保留 `c >= " "`(code point 比较),过滤控制字符(< 空格 U+0020)。
392
396
  let changed = false;
393
- for (const c of data) {
397
+ for (const c of cleaned) {
394
398
  if (c >= " ") {
395
399
  this.editorText += c;
396
400
  changed = true;
@@ -106,25 +106,33 @@ function buildOptionLines(
106
106
  const prefix = isSelected ? t.fg("accent", ">") : " ";
107
107
 
108
108
  if (isOther) {
109
+ // 标记位宽度必须与普通选项一致,否则编号列错位:
110
+ // 单选 check = 1 列,多选 box = 3 列。
111
+ // 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
112
+ // 两种情况下 Other 编号都与普通选项错位。
109
113
  if (state.mode === "freeform") {
110
- // 原地编辑:与普通选项的 [ ] 框视觉对齐(单选无勾选语义则留空)。
111
- // lead = "> [ ] ",编号 + 输入文本跟在框后,内容起始列与普通选项一致。
112
- const box = q.multiSelect ? t.fg("dim", "[ ]") : " ";
114
+ const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
113
115
  const num = i + 1;
114
- const lead = `${prefix} ${box} `;
116
+ const lead = `${prefix} ${marker} `;
115
117
  const avail = Math.max(1, width - visibleWidth(lead));
116
118
  // 编号 + 文本 + 末尾光标 █ 整体软换行(空 input 时仅编号 + 光标,wrapTextWithAnsi 单行)
117
119
  const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", editorText)}${t.fg("accent", "█")}`;
118
120
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
119
121
  } else {
120
122
  const hasFreeText = state.freeTextValue !== null;
121
- const check = hasFreeText ? t.fg("success", "✓") : " ";
123
+ const marker = q.multiSelect
124
+ ? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
125
+ : (hasFreeText ? t.fg("success", "✓") : " ");
122
126
  const labelColor = isSelected ? "accent" : "muted";
123
127
  const num = i + 1;
124
- add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
128
+ add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
125
129
  if (hasFreeText) {
126
- // 已保存 freeText 预览:软换行展示,最多 MAX_EDITOR_LINES 行(不再单行截断)
127
- const lead = " "; // 6 列缩进,与上方 label 行对齐(prefix1 + sp1 + check1 + sp1 + num2 = 6)
130
+ // 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
131
+ // num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
132
+ const numStr = `${num}.`;
133
+ const lead = " ".repeat(
134
+ visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
135
+ );
128
136
  const avail = Math.max(1, width - visibleWidth(lead));
129
137
  const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
130
138
  addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);