@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "0.0.1",
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(TAB); // -> Q2
121
- c.handleInput(TAB); // -> Q3
122
- c.handleInput(TAB); // -> Submit
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 已废弃:←/→ 不再切 tab(C-E5/C-E6 覆盖 Tab/Shift+Tab 行为)
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("\x1b[Z"); // Q1
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(TAB); // → Submit, should auto-confirm Q2
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(TAB); // → Q2,auto-confirm Q1,不进评论
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("\x1b[Z"); // Shift+Tab → Q1
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(TAB); // → Q2
218
- c.handleInput(TAB); // → Q3
219
- c.handleInput(TAB); // → Submit
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(TAB); // Q2
517
- c.handleInput(TAB); // Q3
518
- c.handleInput(TAB); // Submit
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
- // S-12 锁定:Submit tab 上按 Left → activeTab = questions.length - 1(最后一个问题)
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(TAB); // Q1 → Q2
545
- c.handleInput(TAB); // Q2 → Q3
546
- c.handleInput(TAB); // Q3 → Submit
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 上按 Left → 应回到最后一个问题 Q3
551
- c.handleInput("\x1b[Z");
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("\x1b[Z"); // Q2 → Q1
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: Tab navigates to next tab; Shift+Tab to previous", () => {
722
+ it("C-E5: ←/→ navigates question tabs (→ next, previous)", () => {
646
723
  const { c } = make(multiQ);
647
- c.handleInput(TAB); // Q1 → Q2
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
- // Shift+Tab(xterm 序列 ESC[Z)回退到 Q1
651
- c.handleInput("\x1b[Z");
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: Tab wraps Submit Q1, Shift+Tab wraps Q1 Submit", () => {
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(TAB); // Q2→Q3
660
- c.handleInput(TAB); // Q3→Submit
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 上 Tab环绕到 Q1
663
- c.handleInput(TAB);
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: Left/Right on question tab do NOT switch tabs (only Tab/Shift+Tab do)", () => {
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 → 应仍在 Q1(不切 tab)
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
- // 2) Left 也不切
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 Left/Right toggles submitTabFocus (Submit ↔ Cancel)", () => {
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(TAB); // Q1→Q2
727
- c.handleInput(TAB); // Q2→Q3
728
- c.handleInput(TAB); // Q3→Submit
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
- // 按 Right → focus 切到 Cancel
734
- c.handleInput(RIGHT);
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
- // 再按 Left → focus 回 Submit
739
- c.handleInput(LEFT);
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 后,按 Right 把 focus 切到 Cancel
766
- c.handleInput(RIGHT);
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(TAB); // Q2
778
- c.handleInput(TAB); // Q3
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
- // 统计含 'y' 的行数 = input 渲染行数(应被截到 5 行)
224
- const yLines = lines.filter((l) => l.includes("y"));
225
- 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);
226
278
  // 最后一行带省略号(表示还有更多,光标已被省略号取代)
227
- expect(yLines[yLines.length - 1]).toContain("…");
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
- // Tab / Shift+Tab 切换 tab(多问题,options 模式)。←/→ 故意不切 tab——
225
- // 把左右键留给 Submit tab 上的 Submit/Cancel 焦点切换,避免在问题列表上意外跳走。
226
- if (!this.isSingle && matchesKey(data, "tab")) {
227
- this.gotoTab((this.activeTab + 1) % this.totalTabs);
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, "shift+tab")) {
231
- this.gotoTab((this.activeTab - 1 + this.totalTabs) % this.totalTabs);
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
- * - ← / → → 切换 submitTabFocus(Submit Cancel 视觉高亮)
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
- * - Tab / 环绕到首个问题
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
- // ← / → 切换 focus(Submit Cancel)。Tab 单独处理(→ 行为)
299
- if (matchesKey(data, "left") || matchesKey(data, "right")) {
300
- this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
301
- this.invalidate();
302
- this.tui.requestRender();
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 / Shift+Tab → 回退到最后一个问题
306
- if (matchesKey(data, "escape") || matchesKey(data, "shift+tab")) {
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.activeTab = 0;
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
- if (data.length === 1 && data >= " ") {
386
- 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
+ // 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;
@@ -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 时仅光标,wrapTextWithAnsi("█") 单行)
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 = " "; // 5 列缩进,与上方 label 行对齐
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 - 7);
139
- for (const line of wrapped) add(` ${line}`);
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 - 5);
149
- for (const line of wrapped) add(` ${line}`);
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, split ? split.left : width, !!split, editorText);
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 ? "" : " · Tab switch tabs";
312
+ const tabHint = isSingle ? "" : " · ←/→ switch tabs";
309
313
  const actionHint = onOther
310
314
  ? "Enter open editor"
311
315
  : q.multiSelect
@@ -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 · Tab back to first question"));
97
+ add(t.fg("dim", " ←/→ navigate · Tab toggle Submit/Cancel · Enter confirm · Esc back"));
98
98
 
99
99
  return lines;
100
100
  }