@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 +1 -1
- package/src/__tests__/component.test.ts +110 -0
- package/src/__tests__/question-view.test.ts +98 -4
- package/src/component.ts +19 -3
- package/src/question-view.ts +22 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
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
|
-
|
|
387
|
-
|
|
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;
|
package/src/question-view.ts
CHANGED
|
@@ -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} ${
|
|
116
|
+
const lead = `${prefix} ${marker} `;
|
|
115
117
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
116
|
-
// 文本 + 末尾光标 █ 整体软换行(空 input
|
|
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
|
|
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} ${
|
|
128
|
+
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
125
129
|
if (hasFreeText) {
|
|
126
|
-
//
|
|
127
|
-
|
|
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 -
|
|
141
|
-
for (const line of wrapped) add(`
|
|
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 -
|
|
151
|
-
for (const line of wrapped) add(`
|
|
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
|
}
|