@zhushanwen/pi-ask-user 0.0.2 → 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.2",
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",
@@ -362,6 +362,116 @@ describe("AskUserComponent — Other free-text editor", () => {
362
362
  });
363
363
  });
364
364
 
365
+ // ── 5e2. 多字符粘贴(M2/M7:完整保留,过滤控制字符,emoji 不丢)──
366
+ // 回归 MUST_FIX-M2:handleEditorInput 按 code point 迭代粘贴 chunk,去掉了
367
+ // `c.length === 1` 守卫(它误杀 BMP 之外的代理对,如 emoji)。
368
+ describe("AskUserComponent — multi-char paste in editor", () => {
369
+ it("C-PASTE-1: multi-char chunk pasted at once is fully captured", () => {
370
+ // 终端把整段粘贴文本作为一个 data chunk 投递。修复前只取首字符。
371
+ const { c } = make([singleQ]);
372
+ c.handleInput(DOWN);
373
+ c.handleInput(DOWN);
374
+ c.handleInput(ENTER); // 打开 freeform 编辑器
375
+ c.handleInput("hello world"); // 一次粘贴多字符
376
+ const lines = c.render(60);
377
+ const editorLine = lines.find((l) => l.includes("█"));
378
+ expect(editorLine).toBeDefined();
379
+ // 完整文本保留(非仅首字符 "h")
380
+ expect(editorLine).toContain("hello world");
381
+ });
382
+
383
+ it("C-PASTE-2: paste with emoji (surrogate pair) preserves emoji (M2)", () => {
384
+ // 修复前:`c.length === 1` 守卫把代理对(🐛 length===2)过滤掉,emoji 被静默丢弃。
385
+ const { c } = make([singleQ]);
386
+ c.handleInput(DOWN);
387
+ c.handleInput(DOWN);
388
+ c.handleInput(ENTER);
389
+ c.handleInput("fix the 🐛 bug");
390
+ const lines = c.render(60);
391
+ const editorLine = lines.find((l) => l.includes("█"));
392
+ expect(editorLine).toBeDefined();
393
+ // emoji 完整保留,不丢失
394
+ expect(editorLine).toContain("fix the 🐛 bug");
395
+ expect(editorLine).toContain("🐛");
396
+ });
397
+
398
+ it("C-PASTE-3: multi-char chunk with control chars filters control, keeps printable", () => {
399
+ // 粘贴 "ab\tcd":\t(U+0009)< 空格 → 被过滤;a/b/c/d 保留。
400
+ const { c } = make([singleQ]);
401
+ c.handleInput(DOWN);
402
+ c.handleInput(DOWN);
403
+ c.handleInput(ENTER);
404
+ c.handleInput("ab\tcd");
405
+ const lines = c.render(60);
406
+ const editorLine = lines.find((l) => l.includes("█"));
407
+ expect(editorLine).toBeDefined();
408
+ // 可打印字符保留,控制字符 \t 被过滤
409
+ expect(editorLine).toContain("abcd");
410
+ expect(editorLine).not.toContain("\t");
411
+ });
412
+
413
+ it("C-PASTE-4: empty string input is a no-op (no side effects)", () => {
414
+ const { c, result } = make([singleQ]);
415
+ c.handleInput(DOWN);
416
+ c.handleInput(DOWN);
417
+ c.handleInput(ENTER); // 打开编辑器
418
+ const before = c.render(60);
419
+ c.handleInput(""); // 空字符串,无副作用
420
+ const after = c.render(60);
421
+ // 未解析、未崩溃、视图不变
422
+ expect(result.val).toBeUndefined();
423
+ expect(after).toEqual(before);
424
+ // 编辑器仍在(光标在),editorText 仍为空
425
+ expect(after.some((l) => l.includes("█"))).toBe(true);
426
+ });
427
+
428
+ it("C-PASTE-5: single printable char still works (backward-compat)", () => {
429
+ // 回归:修复去掉守卫后单字符行为不变(与 C-27 等价的逐字符输入)。
430
+ const { c } = make([singleQ]);
431
+ c.handleInput(DOWN);
432
+ c.handleInput(DOWN);
433
+ c.handleInput(ENTER);
434
+ c.handleInput("x"); // 单字符
435
+ const lines = c.render(60);
436
+ const editorLine = lines.find((l) => l.includes("█"));
437
+ expect(editorLine).toBeDefined();
438
+ expect(editorLine).toContain("x");
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
+ });
473
+ });
474
+
365
475
  // ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
366
476
  describe("AskUserComponent — comment flow", () => {
367
477
  it("C-33: single-select + allowComment Enter enters comment mode", () => {
@@ -271,11 +271,12 @@ describe("renderQuestionView — Other editor mode", () => {
271
271
  true,
272
272
  huge,
273
273
  );
274
- // 统计含 'y' 的行数 = input 渲染行数(应被截到 5 行)
275
- const yLines = lines.filter((l) => l.includes("y"));
276
- expect(yLines.length).toBe(5);
274
+ // 统计含输入内容的行数 = input 渲染行数(应被截到 5 行)
275
+ // 编号 "3. " 现在是 styled 内容的一部分,首行包裹段 "3." 不含 'y'
276
+ const inputLines = lines.filter((l) => l.includes("y") || l.includes("3."));
277
+ expect(inputLines.length).toBe(5);
277
278
  // 最后一行带省略号(表示还有更多,光标已被省略号取代)
278
- expect(yLines[yLines.length - 1]).toContain("…");
279
+ expect(inputLines[inputLines.length - 1]).toContain("…");
279
280
  });
280
281
 
281
282
  it("Q-30: 已保存 freeText 预览超屏宽时多行换行展示", () => {
@@ -373,6 +374,99 @@ describe("renderQuestionView — help line", () => {
373
374
  });
374
375
  });
375
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
+
376
470
  // ── Q-24 ~ Q-25: 上下文 ─────────────────────────────────
377
471
  describe("renderQuestionView — context", () => {
378
472
  it("Q-24: renders context when present", () => {
package/src/component.ts CHANGED
@@ -382,9 +382,25 @@ export class AskUserComponent implements Component {
382
382
  this.tui.requestRender();
383
383
  return;
384
384
  }
385
- // Printable char
386
- if (data.length === 1 && data >= " ") {
387
- this.editorText += data;
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
+ 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
+ let changed = false;
397
+ for (const c of cleaned) {
398
+ if (c >= " ") {
399
+ this.editorText += c;
400
+ changed = true;
401
+ }
402
+ }
403
+ if (changed) {
388
404
  this.invalidate();
389
405
  this.tui.requestRender();
390
406
  return;
@@ -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 = "> [ ] N. ",输入文本跟在编号后,与其他选项整体一致。
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} ${t.fg("muted", `${num}. `)}`;
116
+ const lead = `${prefix} ${marker} `;
115
117
  const avail = Math.max(1, width - visibleWidth(lead));
116
- // 文本 + 末尾光标 █ 整体软换行(空 input 时仅光标,wrapTextWithAnsi("█") 单行)
117
- const styled = `${t.fg("text", editorText)}${t.fg("accent", "█")}`;
118
+ // 编号 + 文本 + 末尾光标 █ 整体软换行(空 input 时仅编号 + 光标,wrapTextWithAnsi 单行)
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 = " "; // 5 列缩进,与上方 label 行对齐
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);
@@ -137,8 +145,8 @@ function buildOptionLines(
137
145
  const num = i + 1;
138
146
  add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
139
147
  if (opt.description && !hideDescriptions) {
140
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 7);
141
- for (const line of wrapped) add(` ${line}`);
148
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 10);
149
+ for (const line of wrapped) add(` ${line}`);
142
150
  }
143
151
  } else {
144
152
  const isConfirmed = state.selectedIndex === i;
@@ -147,8 +155,8 @@ function buildOptionLines(
147
155
  const num = i + 1;
148
156
  add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
149
157
  if (opt.description && !hideDescriptions) {
150
- const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 5);
151
- for (const line of wrapped) add(` ${line}`);
158
+ const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 8);
159
+ for (const line of wrapped) add(` ${line}`);
152
160
  }
153
161
  }
154
162
  }