@zhushanwen/pi-ask-user 0.0.1 → 0.0.3
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 +3 -1
- package/src/__tests__/component.test.ts +135 -57
- package/src/__tests__/question-view.test.ts +57 -5
- package/src/component.ts +38 -25
- package/src/question-view.ts +15 -11
- package/src/submit-view.ts +3 -3
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.3",
|
|
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",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"devDependencies": {
|
|
28
|
+
"@earendil-works/pi-tui": "*",
|
|
29
|
+
"@sinclair/typebox": "*",
|
|
28
30
|
"@types/node": "^24.0.0",
|
|
29
31
|
"vitest": "^4.1.8"
|
|
30
32
|
},
|
|
@@ -117,21 +117,21 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
117
117
|
|
|
118
118
|
it("C-9: Submit tab blocks when not all confirmed", () => {
|
|
119
119
|
const { c, result } = make(multiQ);
|
|
120
|
-
c.handleInput(
|
|
121
|
-
c.handleInput(
|
|
122
|
-
c.handleInput(
|
|
120
|
+
c.handleInput(RIGHT); // -> Q2
|
|
121
|
+
c.handleInput(RIGHT); // -> Q3
|
|
122
|
+
c.handleInput(RIGHT); // -> Submit
|
|
123
123
|
c.handleInput(ENTER); // should NOT submit
|
|
124
124
|
expect(result.val).toBeUndefined();
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// C-10 / C-11
|
|
127
|
+
// C-10 / C-11 已废弃:Tab/Shift+Tab 不再切问题 tab(C-E5/C-E6 覆盖 ←/→ 切问题 tab 行为)
|
|
128
128
|
|
|
129
129
|
it("C-16 (AC-16): can re-edit confirmed answer", () => {
|
|
130
130
|
const { c, result } = make(multiQ);
|
|
131
131
|
// Q1: select A
|
|
132
132
|
c.handleInput(ENTER); // → Q2
|
|
133
|
-
// Go back to Q1
|
|
134
|
-
c.handleInput(
|
|
133
|
+
// Go back to Q1(问题 tab 间用 ← 回退)
|
|
134
|
+
c.handleInput(LEFT); // Q1
|
|
135
135
|
// Select B instead
|
|
136
136
|
c.handleInput(DOWN);
|
|
137
137
|
c.handleInput(ENTER); // → Q2
|
|
@@ -152,7 +152,7 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
152
152
|
const { c, result } = make(twoQ);
|
|
153
153
|
c.handleInput(ENTER); // Q1 select A → Q2
|
|
154
154
|
c.handleInput(" "); // Q2 toggle X (no Enter-confirm)
|
|
155
|
-
c.handleInput(
|
|
155
|
+
c.handleInput(RIGHT); // → Submit, should auto-confirm Q2
|
|
156
156
|
c.handleInput(ENTER); // Submit (all confirmed)
|
|
157
157
|
expect(result.val!.answers["Q1"]).toBe("A");
|
|
158
158
|
expect(result.val!.answers["Q2"]).toBe("X");
|
|
@@ -179,7 +179,7 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
179
179
|
];
|
|
180
180
|
const { c, result } = make(twoQMulti);
|
|
181
181
|
c.handleInput(" "); // Q1 toggle A
|
|
182
|
-
c.handleInput(
|
|
182
|
+
c.handleInput(RIGHT); // → Q2,auto-confirm Q1,不进评论
|
|
183
183
|
// 验证:当前在 Q2(非 Q1 的评论模式)。Q2 选 X → Submit
|
|
184
184
|
c.handleInput(ENTER); // Q2 select X → Submit
|
|
185
185
|
c.handleInput(ENTER); // Submit
|
|
@@ -201,8 +201,8 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
201
201
|
c.handleInput("o");
|
|
202
202
|
c.handleInput("m");
|
|
203
203
|
c.handleInput(ENTER); // 保存 freeText → confirmed=true → advance to Q2
|
|
204
|
-
// 切回 Q1,重进 Other 编辑器,清空后空 Enter
|
|
205
|
-
c.handleInput(
|
|
204
|
+
// 切回 Q1,重进 Other 编辑器,清空后空 Enter(问题 tab 间用 ← 回退)
|
|
205
|
+
c.handleInput(LEFT); // → Q1
|
|
206
206
|
c.handleInput(DOWN); // idempotent: cursor stays on Other
|
|
207
207
|
c.handleInput(DOWN);
|
|
208
208
|
c.handleInput(ENTER); // 重开 freeform,editorText 预填 "custom"
|
|
@@ -214,9 +214,9 @@ describe("AskUserComponent — multi question tab nav", () => {
|
|
|
214
214
|
c.handleInput(BKSP);
|
|
215
215
|
c.handleInput(ENTER); // 空 Enter → freeTextValue 清空,confirmed 应重置 false
|
|
216
216
|
// 导航到 Submit 并尝试提交 → 应被阻塞(Q1 回到未答)
|
|
217
|
-
c.handleInput(
|
|
218
|
-
c.handleInput(
|
|
219
|
-
c.handleInput(
|
|
217
|
+
c.handleInput(RIGHT); // → Q2
|
|
218
|
+
c.handleInput(RIGHT); // → Q3
|
|
219
|
+
c.handleInput(RIGHT); // → Submit
|
|
220
220
|
c.handleInput(ENTER); // 应被阻塞
|
|
221
221
|
expect(result.val).toBeUndefined();
|
|
222
222
|
});
|
|
@@ -362,6 +362,83 @@ 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
|
+
|
|
365
442
|
// ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
|
|
366
443
|
describe("AskUserComponent — comment flow", () => {
|
|
367
444
|
it("C-33: single-select + allowComment Enter enters comment mode", () => {
|
|
@@ -513,9 +590,9 @@ describe("AskUserComponent — render cache", () => {
|
|
|
513
590
|
describe("AskUserComponent — Submit tab", () => {
|
|
514
591
|
it("C-47: Submit Esc backs to last question (no longer cancels)", () => {
|
|
515
592
|
const { c, result } = make(multiQ);
|
|
516
|
-
c.handleInput(
|
|
517
|
-
c.handleInput(
|
|
518
|
-
c.handleInput(
|
|
593
|
+
c.handleInput(RIGHT); // Q2
|
|
594
|
+
c.handleInput(RIGHT); // Q3
|
|
595
|
+
c.handleInput(RIGHT); // Submit
|
|
519
596
|
c.handleInput(ESC); // 回退到最后一个问题 Q3(不取消)
|
|
520
597
|
expect(result.val).toBeUndefined();
|
|
521
598
|
const lines = c.render(80);
|
|
@@ -538,17 +615,17 @@ describe("AskUserComponent — Submit tab", () => {
|
|
|
538
615
|
});
|
|
539
616
|
|
|
540
617
|
it("C-S12: Submit tab Left navigates to last question tab", () => {
|
|
541
|
-
//
|
|
618
|
+
// 锁定:Submit tab 上按 ← → activeTab = questions.length - 1(最后一个问题)
|
|
542
619
|
const { c } = make(multiQ); // 3 questions → tabs 0,1,2,3=Submit
|
|
543
|
-
// 导航到 Submit
|
|
544
|
-
c.handleInput(
|
|
545
|
-
c.handleInput(
|
|
546
|
-
c.handleInput(
|
|
620
|
+
// 导航到 Submit(问题 tab 间用 →)
|
|
621
|
+
c.handleInput(RIGHT); // Q1 → Q2
|
|
622
|
+
c.handleInput(RIGHT); // Q2 → Q3
|
|
623
|
+
c.handleInput(RIGHT); // Q3 → Submit
|
|
547
624
|
// 确认当前在 Submit(渲染 Submit 视图)
|
|
548
625
|
let lines = c.render(80);
|
|
549
626
|
expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
|
|
550
|
-
// 在 Submit 上按
|
|
551
|
-
c.handleInput(
|
|
627
|
+
// 在 Submit 上按 ← → 应回到最后一个问题 Q3(←/→ 在所有 tab 上都是导航)
|
|
628
|
+
c.handleInput(LEFT);
|
|
552
629
|
lines = c.render(80);
|
|
553
630
|
// Q3 不再是 Submit 视图(无 Ready/Unanswered),且渲染了 Q3 的选项 M
|
|
554
631
|
expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(false);
|
|
@@ -607,8 +684,8 @@ describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () =>
|
|
|
607
684
|
it("C-E1: confirmed tab shows green ✓ marker", () => {
|
|
608
685
|
const { c } = make(multiQ);
|
|
609
686
|
c.handleInput(ENTER); // Q1 确认 → Q2
|
|
610
|
-
// 回到 Q1 看 tab 栏:Q1 已确认应有 ✓
|
|
611
|
-
c.handleInput(
|
|
687
|
+
// 回到 Q1 看 tab 栏:Q1 已确认应有 ✓ 标识(问题 tab 间用 ← 回退)
|
|
688
|
+
c.handleInput(LEFT); // Q2 → Q1
|
|
612
689
|
const lines = c.render(80);
|
|
613
690
|
expect(lines.some((l) => l.includes("✓") && l.includes("First"))).toBe(true);
|
|
614
691
|
});
|
|
@@ -642,29 +719,31 @@ describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () =>
|
|
|
642
719
|
expect(c.render(80).some((l) => l.includes("Cancel all"))).toBe(false);
|
|
643
720
|
});
|
|
644
721
|
|
|
645
|
-
it("C-E5:
|
|
722
|
+
it("C-E5: ←/→ navigates question tabs (→ next, ← previous)", () => {
|
|
646
723
|
const { c } = make(multiQ);
|
|
647
|
-
c.handleInput(
|
|
724
|
+
c.handleInput(RIGHT); // Q1 → Q2
|
|
648
725
|
// 确认在 Q2:渲染含 Q2 的选项 X
|
|
649
726
|
expect(c.render(80).some((l) => l.includes("X"))).toBe(true);
|
|
650
|
-
//
|
|
651
|
-
c.handleInput(
|
|
727
|
+
// ← 回退到 Q1
|
|
728
|
+
c.handleInput(LEFT);
|
|
652
729
|
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
|
|
653
730
|
});
|
|
654
731
|
|
|
655
|
-
it("C-E6:
|
|
732
|
+
it("C-E6: → at last question enters Submit; ← at first question stays (no wrap)", () => {
|
|
656
733
|
const { c } = make(multiQ);
|
|
657
734
|
// 到 Submit:Q1→Q2→Q3→Submit
|
|
658
735
|
c.handleInput(ENTER); // Q1→Q2
|
|
659
|
-
c.handleInput(
|
|
660
|
-
c.handleInput(
|
|
736
|
+
c.handleInput(RIGHT); // Q2→Q3
|
|
737
|
+
c.handleInput(RIGHT); // Q3→Submit
|
|
661
738
|
expect(c.render(80).some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
|
|
662
|
-
// Submit 上
|
|
663
|
-
c.handleInput(
|
|
739
|
+
// Submit 上 ← → 回退到 Q3(←/→ 在所有 tab 上都是导航)
|
|
740
|
+
c.handleInput(LEFT);
|
|
741
|
+
expect(c.render(80).some((l) => l.includes("M"))).toBe(true);
|
|
742
|
+
// Q1 上 ← 不环绕(停在首个问题)
|
|
743
|
+
c.handleInput(LEFT); // Q3→Q2
|
|
744
|
+
c.handleInput(LEFT); // Q2→Q1
|
|
745
|
+
c.handleInput(LEFT); // Q1 上 ← → 停留 Q1
|
|
664
746
|
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
|
|
665
|
-
// Q1 上 Shift+Tab → 环绕回 Submit
|
|
666
|
-
c.handleInput("\x1b[Z");
|
|
667
|
-
expect(c.render(80).some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
|
|
668
747
|
});
|
|
669
748
|
});
|
|
670
749
|
|
|
@@ -704,39 +783,39 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
704
783
|
expect(result.val!.answers["Which features?"]).toBe("Auth, redis");
|
|
705
784
|
});
|
|
706
785
|
|
|
707
|
-
it("C-NEW-2:
|
|
786
|
+
it("C-NEW-2: ←/→ on question tab switches tabs (→ next, ← previous; ← no wrap at first)", () => {
|
|
708
787
|
// multiQ: 3 questions → tabs 0,1,2,3=Submit
|
|
709
788
|
const { c } = make(multiQ);
|
|
710
789
|
c.render(80);
|
|
711
|
-
// 1) Q1 上按 Right →
|
|
790
|
+
// 1) Q1 上按 Right → 应切到 Q2
|
|
712
791
|
c.handleInput(RIGHT);
|
|
792
|
+
expect(c.render(80).some((l) => l.includes("Q2") || l.includes("Second"))).toBe(true);
|
|
793
|
+
// 2) Left 回到 Q1
|
|
794
|
+
c.handleInput(LEFT);
|
|
713
795
|
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
|
|
714
|
-
//
|
|
796
|
+
// 3) Q1 上 Left 不环绕(仍停 Q1)
|
|
715
797
|
c.handleInput(LEFT);
|
|
716
798
|
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
|
|
717
|
-
// 3) Tab 仍然可以切到 Q2
|
|
718
|
-
c.handleInput(TAB);
|
|
719
|
-
expect(c.render(80).some((l) => l.includes("Q2") || l.includes("Second"))).toBe(true);
|
|
720
799
|
});
|
|
721
800
|
|
|
722
|
-
it("C-NEW-3: Submit tab
|
|
801
|
+
it("C-NEW-3: Submit tab Tab toggles submitTabFocus (Submit ↔ Cancel)", () => {
|
|
723
802
|
// multiQ: 3 questions → tabs 0,1,2,3=Submit
|
|
724
803
|
const { c } = make(multiQ);
|
|
725
|
-
// 导航到 Submit
|
|
726
|
-
c.handleInput(
|
|
727
|
-
c.handleInput(
|
|
728
|
-
c.handleInput(
|
|
804
|
+
// 导航到 Submit(问题 tab 间用 →)
|
|
805
|
+
c.handleInput(RIGHT); // Q1→Q2
|
|
806
|
+
c.handleInput(RIGHT); // Q2→Q3
|
|
807
|
+
c.handleInput(RIGHT); // Q3→Submit
|
|
729
808
|
// Submit tab 默认 focus=Submit。验证渲染中 Submit 高亮(accent)
|
|
730
809
|
let lines = c.render(80);
|
|
731
810
|
const focusedLineInitial = lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/));
|
|
732
811
|
expect(focusedLineInitial).toBeDefined();
|
|
733
|
-
// 按
|
|
734
|
-
c.handleInput(
|
|
812
|
+
// 按 Tab → focus 切到 Cancel(单键双向循环)
|
|
813
|
+
c.handleInput(TAB);
|
|
735
814
|
lines = c.render(80);
|
|
736
815
|
const focusedLineAfter = lines.find((l) => l.match(/[\[\(]\s*Cancel\s*[\]\)]/));
|
|
737
816
|
expect(focusedLineAfter).toBeDefined();
|
|
738
|
-
// 再按
|
|
739
|
-
c.handleInput(
|
|
817
|
+
// 再按 Tab → focus 回 Submit
|
|
818
|
+
c.handleInput(TAB);
|
|
740
819
|
lines = c.render(80);
|
|
741
820
|
expect(lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/))).toBeDefined();
|
|
742
821
|
});
|
|
@@ -762,8 +841,8 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
762
841
|
c.handleInput(ENTER); // Q1 → Q2
|
|
763
842
|
c.handleInput(ENTER); // Q2 → Q3
|
|
764
843
|
c.handleInput(ENTER); // Q3 → Submit tab
|
|
765
|
-
// 切到 Submit 后,按
|
|
766
|
-
c.handleInput(
|
|
844
|
+
// 切到 Submit 后,按 Tab 把 focus 切到 Cancel
|
|
845
|
+
c.handleInput(TAB);
|
|
767
846
|
// Enter → 直接 cancel()(Submit tab 上无二次确认)
|
|
768
847
|
c.handleInput(ENTER);
|
|
769
848
|
expect(result.val).toBeNull();
|
|
@@ -774,9 +853,8 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
774
853
|
// 只答 Q1
|
|
775
854
|
c.handleInput(ENTER); // Q1 → Q2
|
|
776
855
|
// 切到 Submit(Q2 还未答)
|
|
777
|
-
c.handleInput(
|
|
778
|
-
c.handleInput(
|
|
779
|
-
c.handleInput(TAB); // Submit
|
|
856
|
+
c.handleInput(RIGHT); // Q2 → Q3
|
|
857
|
+
c.handleInput(RIGHT); // Q3 → Submit
|
|
780
858
|
// focus=Submit(默认),按 Enter → 不提交(Q2/Q3 未答)
|
|
781
859
|
c.handleInput(ENTER);
|
|
782
860
|
expect(result.val).toBeUndefined();
|
|
@@ -138,7 +138,7 @@ describe("renderQuestionView — split pane", () => {
|
|
|
138
138
|
|
|
139
139
|
// ── Q-15 ~ Q-17: Other 编辑器模式 ───────────────────────
|
|
140
140
|
describe("renderQuestionView — Other editor mode", () => {
|
|
141
|
-
it("Q-15: freeform mode renders Other row in-place with draft + cursor", () => {
|
|
141
|
+
it("Q-15: freeform mode renders Other row in-place with draft + cursor + number prefix", () => {
|
|
142
142
|
const lines = renderQuestionView(
|
|
143
143
|
singleQ,
|
|
144
144
|
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
@@ -153,6 +153,32 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
153
153
|
expect(t).toContain("█");
|
|
154
154
|
// 不再独立 "Your answer" 提示行
|
|
155
155
|
expect(t).not.toContain("Your answer");
|
|
156
|
+
// 需求4:Other 在 freeform 态也有编号前缀(与其他选项一致)。
|
|
157
|
+
// singleQ 有 2 个选项 → Other 是第 3 项,lead 应含 "3. "
|
|
158
|
+
expect(t).toContain("3. ");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("Q-15-NUM: multi-select freeform Other row shows [ ] + number prefix", () => {
|
|
162
|
+
// Auth(1), Search(2), Other(3),多选 freeform 应渲染 "> [ ] 3. <input>█"
|
|
163
|
+
const multiQ: Question = {
|
|
164
|
+
question: "Which features?",
|
|
165
|
+
multiSelect: true,
|
|
166
|
+
options: [{ label: "Auth" }, { label: "Search" }],
|
|
167
|
+
};
|
|
168
|
+
const lines = renderQuestionView(
|
|
169
|
+
multiQ,
|
|
170
|
+
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
171
|
+
stubTheme,
|
|
172
|
+
60,
|
|
173
|
+
true,
|
|
174
|
+
"custom",
|
|
175
|
+
);
|
|
176
|
+
const t = text(lines);
|
|
177
|
+
// 多选 box [ ] + 编号 3. 都在编辑行上
|
|
178
|
+
expect(t).toContain("[ ]");
|
|
179
|
+
expect(t).toContain("3. ");
|
|
180
|
+
expect(t).toContain("custom");
|
|
181
|
+
expect(t).toContain("█");
|
|
156
182
|
});
|
|
157
183
|
|
|
158
184
|
it("Q-16: Other with saved free-text shows checkmark + preview", () => {
|
|
@@ -208,6 +234,31 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
208
234
|
}
|
|
209
235
|
});
|
|
210
236
|
|
|
237
|
+
it("Q-28-WIDE: freeform 模式在宽终端下用全宽渲染(不被分屏左列压窄)", () => {
|
|
238
|
+
// 回归:freeform/comment 模式忽略分屏,编辑器用全 width。
|
|
239
|
+
// 修复前:宽终端走 split.left(≈40),Other 输入被压在左半屏换行频繁。
|
|
240
|
+
const width = 100;
|
|
241
|
+
// getSplitPaneWidths(100) 非 null(宽终端会进分屏分支),但 freeform 应绕过它
|
|
242
|
+
expect(getSplitPaneWidths(width)).not.toBeNull();
|
|
243
|
+
// 单选 lead visLen=5 → avail = width - 5 = 95。用 60 字符(< 95)应单行装下。
|
|
244
|
+
// 修复前若用 split.left≈40,avail≈35 → 60 字符会换 2 行。
|
|
245
|
+
const input = "a".repeat(60);
|
|
246
|
+
const lines = renderQuestionView(
|
|
247
|
+
singleQ,
|
|
248
|
+
makeState({ mode: "freeform", cursorIndex: 2 }),
|
|
249
|
+
stubTheme,
|
|
250
|
+
width,
|
|
251
|
+
true,
|
|
252
|
+
input,
|
|
253
|
+
);
|
|
254
|
+
// 用 █ 定位编辑器行(help 行不含 █),排除 “submit” 含 a 的干扰
|
|
255
|
+
const editorLines = lines.filter((l) => l.includes("█"));
|
|
256
|
+
expect(editorLines.length).toBe(1);
|
|
257
|
+
// 全部 60 个 a 都在这一行(未被换行拆分)
|
|
258
|
+
const aCount = editorLines[0]!.split("").filter((c) => c === "a").length;
|
|
259
|
+
expect(aCount).toBe(60);
|
|
260
|
+
});
|
|
261
|
+
|
|
211
262
|
it("Q-29: freeform 输入超 5 行时截断到 5 行并加省略号", () => {
|
|
212
263
|
const width = 30;
|
|
213
264
|
// avail=24,200 字符 → 9 行,超过 5 行上限
|
|
@@ -220,11 +271,12 @@ describe("renderQuestionView — Other editor mode", () => {
|
|
|
220
271
|
true,
|
|
221
272
|
huge,
|
|
222
273
|
);
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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);
|
|
226
278
|
// 最后一行带省略号(表示还有更多,光标已被省略号取代)
|
|
227
|
-
expect(
|
|
279
|
+
expect(inputLines[inputLines.length - 1]).toContain("…");
|
|
228
280
|
});
|
|
229
281
|
|
|
230
282
|
it("Q-30: 已保存 freeText 预览超屏宽时多行换行展示", () => {
|
package/src/component.ts
CHANGED
|
@@ -221,14 +221,15 @@ export class AskUserComponent implements Component {
|
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
// ← / → 切换问题 tab(多问题,options 模式):末尾 → 进入 Submit tab,
|
|
225
|
+
// 首个 ← 停在首个问题(不环绕)。Tab/Shift+Tab 不在问题 tab 切 tab——
|
|
226
|
+
// 留给 Submit tab 上的 Submit/Cancel 焦点切换。
|
|
227
|
+
if (!this.isSingle && matchesKey(data, "right")) {
|
|
228
|
+
this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
|
|
228
229
|
return;
|
|
229
230
|
}
|
|
230
|
-
if (!this.isSingle && matchesKey(data, "
|
|
231
|
-
this.gotoTab((this.activeTab - 1
|
|
231
|
+
if (!this.isSingle && matchesKey(data, "left")) {
|
|
232
|
+
this.gotoTab(Math.max(this.activeTab - 1, 0));
|
|
232
233
|
return;
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -285,33 +286,33 @@ export class AskUserComponent implements Component {
|
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
/**
|
|
288
|
-
* Submit tab
|
|
289
|
-
* - ← / → →
|
|
289
|
+
* Submit tab 输入路由(键位语义与问题 tab 全局一致):
|
|
290
|
+
* - ← / → → tab 导航(← 回到最后一个问题;→ 环绕到首个问题)
|
|
291
|
+
* - Tab → 在 Submit ↔ Cancel 间循环切焦点(单键双向,不依赖 shift+tab,
|
|
292
|
+
* 规避 Pi 全局 app.thinking.cycle 对 shift+tab 的拦截)
|
|
290
293
|
* - Enter → 触发当前 focus 项:Submit=allConfirmed 才提交;Cancel=直接取消
|
|
291
|
-
* -
|
|
292
|
-
* - Esc / Shift+Tab → 回退到最后一个问题
|
|
293
|
-
*
|
|
294
|
-
* 注意:Submit tab 上 ←/→ 不再切回问题区(与问题 tab 上的"←/→ 不切 tab"对称,
|
|
295
|
-
* 避免在 Submit 视图内意外跳走)。
|
|
294
|
+
* - Esc → 回退到最后一个问题(与问题 tab 的 Esc-back 语义一致)
|
|
296
295
|
*/
|
|
297
296
|
private handleSubmitTabInput(data: string): void {
|
|
298
|
-
// ← / →
|
|
299
|
-
if (matchesKey(data, "left")
|
|
300
|
-
this.
|
|
301
|
-
|
|
302
|
-
|
|
297
|
+
// ← / → → tab 导航(与问题 tab 一致:方向键管 tab 间移动)
|
|
298
|
+
if (matchesKey(data, "left")) {
|
|
299
|
+
this.gotoTab(this.questions.length - 1);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (matchesKey(data, "right")) {
|
|
303
|
+
this.gotoTab(0);
|
|
303
304
|
return;
|
|
304
305
|
}
|
|
305
|
-
// Esc
|
|
306
|
-
if (matchesKey(data, "escape")
|
|
306
|
+
// Esc → 回退到最后一个问题
|
|
307
|
+
if (matchesKey(data, "escape")) {
|
|
307
308
|
this.activeTab = this.questions.length - 1;
|
|
308
309
|
this.invalidate();
|
|
309
310
|
this.tui.requestRender();
|
|
310
311
|
return;
|
|
311
312
|
}
|
|
312
|
-
// Tab →
|
|
313
|
+
// Tab → Submit ↔ Cancel 循环切焦点(单键双向)
|
|
313
314
|
if (matchesKey(data, "tab")) {
|
|
314
|
-
this.
|
|
315
|
+
this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
|
|
315
316
|
this.invalidate();
|
|
316
317
|
this.tui.requestRender();
|
|
317
318
|
return;
|
|
@@ -381,9 +382,21 @@ export class AskUserComponent implements Component {
|
|
|
381
382
|
this.tui.requestRender();
|
|
382
383
|
return;
|
|
383
384
|
}
|
|
384
|
-
// Printable char
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
// for...of 按 code point 迭代:代理对(如 😀 U+1F600)作为一个 c(length===2)出现,
|
|
390
|
+
// 因此不能用 `c.length === 1` 守卫——那会把所有 BMP 之外的字符(emoji、部分 CJK 扩展)全过滤。
|
|
391
|
+
// 只保留 `c >= " "`(code point 比较),过滤控制字符(< 空格 U+0020)。
|
|
392
|
+
let changed = false;
|
|
393
|
+
for (const c of data) {
|
|
394
|
+
if (c >= " ") {
|
|
395
|
+
this.editorText += c;
|
|
396
|
+
changed = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (changed) {
|
|
387
400
|
this.invalidate();
|
|
388
401
|
this.tui.requestRender();
|
|
389
402
|
return;
|
package/src/question-view.ts
CHANGED
|
@@ -107,12 +107,14 @@ function buildOptionLines(
|
|
|
107
107
|
|
|
108
108
|
if (isOther) {
|
|
109
109
|
if (state.mode === "freeform") {
|
|
110
|
-
//
|
|
110
|
+
// 原地编辑:与普通选项的 [ ] 框视觉对齐(单选无勾选语义则留空)。
|
|
111
|
+
// lead = "> [ ] ",编号 + 输入文本跟在框后,内容起始列与普通选项一致。
|
|
111
112
|
const box = q.multiSelect ? t.fg("dim", "[ ]") : " ";
|
|
113
|
+
const num = i + 1;
|
|
112
114
|
const lead = `${prefix} ${box} `;
|
|
113
115
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
114
|
-
// 文本 + 末尾光标 █ 整体软换行(空 input
|
|
115
|
-
const styled = `${t.fg("text", editorText)}${t.fg("accent", "█")}`;
|
|
116
|
+
// 编号 + 文本 + 末尾光标 █ 整体软换行(空 input 时仅编号 + 光标,wrapTextWithAnsi 单行)
|
|
117
|
+
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", editorText)}${t.fg("accent", "█")}`;
|
|
116
118
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
117
119
|
} else {
|
|
118
120
|
const hasFreeText = state.freeTextValue !== null;
|
|
@@ -122,7 +124,7 @@ function buildOptionLines(
|
|
|
122
124
|
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
123
125
|
if (hasFreeText) {
|
|
124
126
|
// 已保存 freeText 预览:软换行展示,最多 MAX_EDITOR_LINES 行(不再单行截断)
|
|
125
|
-
const lead = "
|
|
127
|
+
const lead = " "; // 6 列缩进,与上方 label 行对齐(prefix1 + sp1 + check1 + sp1 + num2 = 6)
|
|
126
128
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
127
129
|
const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
|
|
128
130
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
@@ -135,8 +137,8 @@ function buildOptionLines(
|
|
|
135
137
|
const num = i + 1;
|
|
136
138
|
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
137
139
|
if (opt.description && !hideDescriptions) {
|
|
138
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
139
|
-
for (const line of wrapped) add(`
|
|
140
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 10);
|
|
141
|
+
for (const line of wrapped) add(` ${line}`);
|
|
140
142
|
}
|
|
141
143
|
} else {
|
|
142
144
|
const isConfirmed = state.selectedIndex === i;
|
|
@@ -145,8 +147,8 @@ function buildOptionLines(
|
|
|
145
147
|
const num = i + 1;
|
|
146
148
|
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
147
149
|
if (opt.description && !hideDescriptions) {
|
|
148
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
149
|
-
for (const line of wrapped) add(`
|
|
150
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - 8);
|
|
151
|
+
for (const line of wrapped) add(` ${line}`);
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
}
|
|
@@ -277,10 +279,12 @@ export function renderQuestionView(
|
|
|
277
279
|
divider();
|
|
278
280
|
}
|
|
279
281
|
|
|
280
|
-
// 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines
|
|
282
|
+
// 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines 原地渲染)。
|
|
283
|
+
// 编辑器模式一律用全 width 单列渲染——分屏左列仅约 42% 宽,Other 自由输入会被压窄换行,
|
|
284
|
+
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
281
285
|
if (state.mode === "freeform" || state.mode === "comment") {
|
|
282
286
|
add("");
|
|
283
|
-
const optionLines = buildOptionLines(q, state, theme,
|
|
287
|
+
const optionLines = buildOptionLines(q, state, theme, width, false, editorText);
|
|
284
288
|
for (const line of optionLines) add(line);
|
|
285
289
|
const editorBlock = buildEditorBlock(theme, width, state.mode, editorText);
|
|
286
290
|
lines.push(...editorBlock);
|
|
@@ -305,7 +309,7 @@ export function renderQuestionView(
|
|
|
305
309
|
// 帮助行(上下文相关)
|
|
306
310
|
const opts = allOptions(q);
|
|
307
311
|
const onOther = state.cursorIndex === opts.length - 1;
|
|
308
|
-
const tabHint = isSingle ? "" : " ·
|
|
312
|
+
const tabHint = isSingle ? "" : " · ←/→ switch tabs";
|
|
309
313
|
const actionHint = onOther
|
|
310
314
|
? "Enter open editor"
|
|
311
315
|
: q.multiSelect
|
package/src/submit-view.ts
CHANGED
|
@@ -35,8 +35,8 @@ export function getAnswerText(q: Question, s: QuestionState): string | null {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* 渲染 Submit tab 视图。
|
|
38
|
-
* focus: 当前在 [Submit]/[Cancel]
|
|
39
|
-
* 渲染内嵌按钮栏高亮 focus;help 行更新为 "←/→ toggle · Enter confirm"。
|
|
38
|
+
* focus: 当前在 [Submit]/[Cancel] 上的焦点(Tab 切换)。
|
|
39
|
+
* 渲染内嵌按钮栏高亮 focus;help 行更新为 "←/→ navigate · Tab toggle · Enter confirm"。
|
|
40
40
|
*/
|
|
41
41
|
export function renderSubmitView(
|
|
42
42
|
questions: Question[],
|
|
@@ -94,7 +94,7 @@ export function renderSubmitView(
|
|
|
94
94
|
|
|
95
95
|
// Submit tab 帮助行
|
|
96
96
|
add("");
|
|
97
|
-
add(t.fg("dim", " ←/→ toggle · Enter confirm ·
|
|
97
|
+
add(t.fg("dim", " ←/→ navigate · Tab toggle Submit/Cancel · Enter confirm · Esc back"));
|
|
98
98
|
|
|
99
99
|
return lines;
|
|
100
100
|
}
|