@zhushanwen/pi-ask-user 0.0.4 → 0.2.0

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/src/component.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  // src/component.ts
2
- import { type Component, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
3
3
 
4
+ import {
5
+ deleteCharBeforeCursor,
6
+ handleEditorPaste,
7
+ insertAtCursor,
8
+ moveCursorEnd,
9
+ moveCursorHome,
10
+ moveCursorLeft,
11
+ moveCursorRight,
12
+ } from "./editor-ops";
4
13
  import { allOptions, renderQuestionView } from "./question-view";
5
- import { buildResult, renderSubmitView } from "./submit-view";
14
+ import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
6
15
  import {
7
16
  createQuestionState,
8
17
  HEADER_MAX_CHARS,
@@ -32,7 +41,6 @@ export class AskUserComponent implements Component {
32
41
 
33
42
  private states: QuestionState[];
34
43
  private activeTab: number = 0;
35
- private editorText: string = "";
36
44
 
37
45
  /** Submit tab 上的左右焦点:默认 Submit;← / → 切换;Enter 触发当前项。
38
46
  * 问题 tab 上无意义(仅视觉占位),不参与输入路由。 */
@@ -75,6 +83,13 @@ export class AskUserComponent implements Component {
75
83
  this.cachedLines = undefined;
76
84
  }
77
85
 
86
+ /** 状态变更后的标准后续:失效缓存 + 请求重绘。
87
+ * 消除散弹式修改——每个 mutation 方法末尾不再需要手写 invalidate + requestRender 两行。 */
88
+ private rerender(): void {
89
+ this.invalidate();
90
+ this.tui.requestRender();
91
+ }
92
+
78
93
  // ── 渲染 ──
79
94
  render(width: number): string[] {
80
95
  if (this.cachedWidth === width && this.cachedLines) {
@@ -107,7 +122,7 @@ export class AskUserComponent implements Component {
107
122
  } else {
108
123
  const q = this.questions[this.activeTab]!;
109
124
  const state = this.states[this.activeTab]!;
110
- for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, this.editorText)) {
125
+ for (const line of renderQuestionView({ question: q, state, theme: t, width: innerWidth, isSingle: this.isSingle })) {
111
126
  add(line);
112
127
  }
113
128
  }
@@ -115,7 +130,7 @@ export class AskUserComponent implements Component {
115
130
  // 多问题:底部按钮栏(Submit / Cancel)。Submit tab 上不重复渲染(renderSubmitView 内嵌 focus 高亮)
116
131
  if (!this.isSingle && this.activeTab < this.questions.length) {
117
132
  inner.push("");
118
- this.renderButtonBar(innerWidth, add, null);
133
+ add(renderButtonBar(this.theme, this.allConfirmed(), null));
119
134
  }
120
135
 
121
136
  // 用 box 边框包裹:每行 pad 到 innerWidth 后加 │ 左右边框
@@ -162,28 +177,6 @@ export class AskUserComponent implements Component {
162
177
  add(truncateToWidth(parts.join(""), innerWidth));
163
178
  }
164
179
 
165
- /**
166
- * 底部按钮栏:[ Submit ] [ Cancel ]。
167
- * focus: null=纯展示(问题 tab),"submit"/"cancel"=高亮对应按钮(Submit tab)。
168
- */
169
- private renderButtonBar(
170
- innerWidth: number,
171
- add: (s: string) => void,
172
- focus: "submit" | "cancel" | null,
173
- ): void {
174
- const t = this.theme;
175
- const allDone = this.allConfirmed();
176
- const isSubmit = focus === "submit";
177
- const isCancel = focus === "cancel";
178
- const submit = isSubmit
179
- ? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
180
- : (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
181
- const cancel = isCancel
182
- ? t.fg("accent", t.bold(" Cancel "))
183
- : t.fg("muted", " Cancel ");
184
- add(`${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`);
185
- }
186
-
187
180
  // ── 输入路由 ──
188
181
  handleInput(data: string): void {
189
182
  if (this._resolved) return;
@@ -194,8 +187,7 @@ export class AskUserComponent implements Component {
194
187
  this.cancel();
195
188
  } else {
196
189
  this.pendingCancel = false;
197
- this.invalidate();
198
- this.tui.requestRender();
190
+ this.rerender();
199
191
  }
200
192
  return;
201
193
  }
@@ -215,15 +207,22 @@ export class AskUserComponent implements Component {
215
207
  return;
216
208
  }
217
209
 
210
+ // options mode → delegate to handleOptionsInput
211
+ this.handleOptionsInput(data, state, q);
212
+ }
213
+
214
+ /**
215
+ * options 模式的输入处理(从 handleInput 拆出)。
216
+ * 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
217
+ */
218
+ private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
218
219
  // Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
219
220
  if (matchesKey(data, "escape")) {
220
221
  this.escBackOrConfirm();
221
222
  return;
222
223
  }
223
224
 
224
- // ← / → 切换问题 tab(多问题,options 模式):末尾 → 进入 Submit tab,
225
- // 首个 ← 停在首个问题(不环绕)。Tab/Shift+Tab 不在问题 tab 切 tab——
226
- // 留给 Submit tab 上的 Submit/Cancel 焦点切换。
225
+ // ← / → 切换问题 tab(多问题,options 模式)
227
226
  if (!this.isSingle && matchesKey(data, "right")) {
228
227
  this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
229
228
  return;
@@ -235,29 +234,26 @@ export class AskUserComponent implements Component {
235
234
 
236
235
  if (matchesKey(data, "up")) {
237
236
  state.cursorIndex = Math.max(0, state.cursorIndex - 1);
238
- this.invalidate();
239
- this.tui.requestRender();
237
+ this.rerender();
240
238
  return;
241
239
  }
242
240
  if (matchesKey(data, "down")) {
243
241
  const max = allOptions(q).length - 1;
244
242
  state.cursorIndex = Math.min(max, state.cursorIndex + 1);
245
- this.invalidate();
246
- this.tui.requestRender();
243
+ this.rerender();
247
244
  return;
248
245
  }
249
246
 
250
247
  const opts = allOptions(q);
251
248
  const onOther = state.cursorIndex === opts.length - 1;
252
249
 
253
- // Other row → Enter opens freeform editor(单选/多选统一入口)。
254
- // 单选/多选普通选项的 Enter 各自有不同语义(确认/加入选中),与 Other 互斥:
255
- // onOther 时本分支先消费 Enter,下面多选/单选分支不再处理。
250
+ // Other row → Enter opens freeform editor
256
251
  if (onOther && matchesKey(data, "enter")) {
252
+ state.savedOptionsCursorIndex = state.cursorIndex;
257
253
  state.mode = "freeform";
258
- this.editorText = state.freeTextValue ?? "";
259
- this.invalidate();
260
- this.tui.requestRender();
254
+ state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
255
+ state.cursorIndex = state.draftText.length;
256
+ this.rerender();
261
257
  return;
262
258
  }
263
259
 
@@ -267,10 +263,6 @@ export class AskUserComponent implements Component {
267
263
  return;
268
264
  }
269
265
  if (matchesKey(data, "enter")) {
270
- // Enter 先把光标所在的普通选项加入选中再确认,与单选分支(Enter 时
271
- // selectedIndex = cursorIndex)保持一致。即使 freeTextValue 已存在
272
- // (Other 录入),也尊重「在此选项上按 Enter 想选中它」的意图,避免
273
- // 静默用旧 Other 文本确认。add 幂等,不会误取消已选项。
274
266
  state.selectedIndices.add(state.cursorIndex);
275
267
  this.afterConfirm(state, q);
276
268
  return;
@@ -306,15 +298,13 @@ export class AskUserComponent implements Component {
306
298
  // Esc → 回退到最后一个问题
307
299
  if (matchesKey(data, "escape")) {
308
300
  this.activeTab = this.questions.length - 1;
309
- this.invalidate();
310
- this.tui.requestRender();
301
+ this.rerender();
311
302
  return;
312
303
  }
313
304
  // Tab → Submit ↔ Cancel 循环切焦点(单键双向)
314
305
  if (matchesKey(data, "tab")) {
315
306
  this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
316
- this.invalidate();
317
- this.tui.requestRender();
307
+ this.rerender();
318
308
  return;
319
309
  }
320
310
  // Enter → 触发当前 focus
@@ -330,81 +320,97 @@ export class AskUserComponent implements Component {
330
320
  }
331
321
 
332
322
  private handleEditorInput(data: string, state: QuestionState, q: Question): void {
323
+ // parseKey 白名单拦截 — 替代旧的 matchesKey 散调 + 兜底 printable 遍历
324
+ const keyId = parseKey(data);
325
+ if (keyId !== undefined) {
326
+ this.handleEditorKey(data, keyId, state, q);
327
+ return;
328
+ }
329
+ if (handleEditorPaste(state, data)) this.rerender();
330
+ }
331
+
332
+ /** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
333
+ * 其他 special key(功能键/modifier 组合)no-op。 */
334
+ private handleEditorKey(data: string, keyId: string, state: QuestionState, q: Question): void {
333
335
  if (matchesKey(data, "escape")) {
334
- if (state.mode === "comment") {
335
- // AC-17: Esc in comment = skip comment, advance (keep existing commentValue)
336
- state.mode = "options";
337
- this.editorText = "";
338
- this.advance();
339
- return;
340
- }
341
- // Esc in freeform editor = back to options (discard input)
342
- state.mode = "options";
343
- this.editorText = "";
344
- this.invalidate();
345
- this.tui.requestRender();
336
+ this.handleEditorEsc(state);
346
337
  return;
347
338
  }
348
339
  if (matchesKey(data, "enter")) {
349
- const text = this.editorText.trim();
350
- if (state.mode === "freeform") {
351
- if (text) {
352
- state.freeTextValue = text;
353
- state.selectedIndex = null;
354
- state.mode = "options";
355
- this.editorText = "";
356
- // freeform 保存后走 afterConfirm(可能进 comment 模式)
357
- this.afterConfirm(state, q);
358
- } else {
359
- // FR-6: 空 Enter 仅清除 freeTextValue、关闭编辑器回选项列表,不含确认语义(不置 confirmed)
360
- state.freeTextValue = null;
361
- state.mode = "options";
362
- this.editorText = "";
363
- // 对齐 toggleIndex 守卫:清空后若全无答案,重置 confirmed,维持 confirmed ⟹ 有答案 不变式
364
- if ((q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) && state.freeTextValue === null) {
365
- state.confirmed = false;
366
- }
367
- this.invalidate();
368
- this.tui.requestRender();
369
- }
370
- return;
371
- }
372
- // comment mode:保存评论后直接前进(不再回头进 comment)
373
- state.commentValue = text || null;
374
- state.mode = "options";
375
- this.editorText = "";
376
- this.advance();
340
+ this.handleEditorEnter(state, q);
377
341
  return;
378
342
  }
379
343
  if (matchesKey(data, "backspace")) {
380
- this.editorText = this.editorText.slice(0, -1);
381
- this.invalidate();
382
- this.tui.requestRender();
344
+ if (deleteCharBeforeCursor(state)) this.rerender();
383
345
  return;
384
346
  }
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
- }
347
+ // 光标移动(4 方向 + home/end)
348
+ if (matchesKey(data, "left")) { moveCursorLeft(state); this.rerender(); return; }
349
+ if (matchesKey(data, "right")) { moveCursorRight(state); this.rerender(); return; }
350
+ if (matchesKey(data, "home")) { moveCursorHome(state); this.rerender(); return; }
351
+ if (matchesKey(data, "end")) { moveCursorEnd(state); this.rerender(); return; }
352
+ // 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
353
+ if (matchesKey(data, "space")) {
354
+ insertAtCursor(state, " ");
355
+ this.rerender();
356
+ return;
357
+ }
358
+ // 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
359
+ if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
360
+ insertAtCursor(state, keyId);
361
+ this.rerender();
362
+ return;
363
+ }
364
+ // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
365
+ }
366
+
367
+ /** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
368
+ private handleEditorEsc(state: QuestionState): void {
369
+ if (state.mode === "comment") {
370
+ // comment Esc: skip comment, advance (keep existing commentValue)
371
+ state.mode = "options";
372
+ state.draftText = "";
373
+ state.cursorIndex = state.savedOptionsCursorIndex;
374
+ this.advance();
375
+ return;
402
376
  }
403
- if (changed) {
404
- this.invalidate();
405
- this.tui.requestRender();
377
+ // freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
378
+ // so discarded drafts don't pollute the answer or trigger auto-confirm.
379
+ state.freeDraft = state.draftText || null;
380
+ state.mode = "options";
381
+ state.draftText = "";
382
+ state.cursorIndex = state.savedOptionsCursorIndex;
383
+ this.rerender();
384
+ }
385
+
386
+ /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
387
+ private handleEditorEnter(state: QuestionState, q: Question): void {
388
+ const text = state.draftText.trim();
389
+ if (state.mode === "freeform") {
390
+ state.cursorIndex = state.savedOptionsCursorIndex;
391
+ if (text) {
392
+ state.freeTextValue = text;
393
+ state.selectedIndex = null;
394
+ state.mode = "options";
395
+ state.draftText = "";
396
+ this.afterConfirm(state, q);
397
+ } else {
398
+ state.freeTextValue = null;
399
+ state.mode = "options";
400
+ state.draftText = "";
401
+ // freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
402
+ if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
403
+ state.confirmed = false;
404
+ }
405
+ this.rerender();
406
+ }
406
407
  return;
407
408
  }
409
+ state.commentValue = text || null;
410
+ state.mode = "options";
411
+ state.draftText = "";
412
+ state.cursorIndex = state.savedOptionsCursorIndex;
413
+ this.advance();
408
414
  }
409
415
 
410
416
  private toggleIndex(state: QuestionState, index: number): void {
@@ -413,8 +419,7 @@ export class AskUserComponent implements Component {
413
419
  if (state.selectedIndices.size === 0 && state.freeTextValue === null) {
414
420
  state.confirmed = false;
415
421
  }
416
- this.invalidate();
417
- this.tui.requestRender();
422
+ this.rerender();
418
423
  }
419
424
 
420
425
  private autoConfirmIfAnswered(): void {
@@ -431,33 +436,29 @@ export class AskUserComponent implements Component {
431
436
  private gotoTab(target: number): void {
432
437
  this.autoConfirmIfAnswered();
433
438
  this.activeTab = target;
434
- this.invalidate();
435
- this.tui.requestRender();
439
+ this.rerender();
436
440
  }
437
441
 
438
442
  /** Esc 语义:有上一个 tab 则回退;已在首个(或单问题)则进入确认取消覆盖层。 */
439
443
  private escBackOrConfirm(): void {
440
444
  if (this.activeTab > 0) {
441
445
  this.activeTab--;
442
- this.invalidate();
443
- this.tui.requestRender();
446
+ this.rerender();
444
447
  return;
445
448
  }
446
449
  this.pendingCancel = true;
447
- this.invalidate();
448
- this.tui.requestRender();
450
+ this.rerender();
449
451
  }
450
452
 
451
453
  /** 选中确认后的处理:若 allowComment,进入评论模式(可重入编辑/清除已有评论);否则前进。 */
452
454
  private afterConfirm(state: QuestionState, q: Question): void {
453
455
  state.confirmed = true;
454
456
  if (q.allowComment && state.mode !== "comment") {
455
- // 进入评论输入行。预填已有评论,空 Enter=清除、新文本=覆盖(FR-4 item6)。
456
- // 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
457
+ state.savedOptionsCursorIndex = state.cursorIndex;
457
458
  state.mode = "comment";
458
- this.editorText = state.commentValue ?? "";
459
- this.invalidate();
460
- this.tui.requestRender();
459
+ state.draftText = state.commentValue ?? "";
460
+ state.cursorIndex = state.draftText.length;
461
+ this.rerender();
461
462
  return;
462
463
  }
463
464
  this.advance();
@@ -473,8 +474,7 @@ export class AskUserComponent implements Component {
473
474
  } else {
474
475
  this.activeTab = this.questions.length;
475
476
  }
476
- this.invalidate();
477
- this.tui.requestRender();
477
+ this.rerender();
478
478
  }
479
479
 
480
480
  private submit(): void {
@@ -482,8 +482,11 @@ export class AskUserComponent implements Component {
482
482
  this.done(buildResult(this.questions, this.states));
483
483
  }
484
484
 
485
- /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态) */
485
+ /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态)。
486
+ * 守卫在方法内:signal abort 可能在用户已 submit/cancel 后才触发,
487
+ * 此时必须 no-op,避免二次调 done(FR-12)。 */
486
488
  cancel(): void {
489
+ if (this._resolved) return;
487
490
  this._resolved = true;
488
491
  this.done(null);
489
492
  }
@@ -0,0 +1,75 @@
1
+ // src/editor-ops.ts
2
+ // 文本编辑器的纯操作函数——操作 QuestionState.draftText/cursorIndex。
3
+ // 从 AskUserComponent 提取,使 component 聚焦于问卷交互(tab 导航/状态转换/渲染编排),
4
+ // 不再理解终端转义序列解析和 surrogate pair 细节。
5
+ //
6
+ // 所有函数直接修改传入的 state(mutation 风格,与调用方 component 的命令式风格一致)。
7
+ // 是否产生了变更由返回值表达,调用方据此决定是否 rerender。
8
+
9
+ import { isHighSurrogate, SURROGATE_PAIR_LEN, type QuestionState } from "./types";
10
+
11
+ // ── 编辑器纯操作 ──
12
+
13
+ // ── 编辑器纯操作 ──
14
+
15
+ /** 在光标处插入文本,光标前移 text.length。 */
16
+ export function insertAtCursor(state: QuestionState, text: string): void {
17
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
18
+ state.cursorIndex += text.length;
19
+ }
20
+
21
+ /** 删除光标前一个 code point(surrogate pair 时删整个 code point)。
22
+ * 返回 true 表示有删除发生(调用方据此决定是否 invalidate)。 */
23
+ export function deleteCharBeforeCursor(state: QuestionState): boolean {
24
+ if (state.cursorIndex <= 0) return false;
25
+ const deleteCount = state.cursorIndex >= SURROGATE_PAIR_LEN && isHighSurrogate(state.draftText, state.cursorIndex - SURROGATE_PAIR_LEN) ? SURROGATE_PAIR_LEN : 1;
26
+ state.draftText = state.draftText.slice(0, state.cursorIndex - deleteCount) + state.draftText.slice(state.cursorIndex);
27
+ state.cursorIndex -= deleteCount;
28
+ return true;
29
+ }
30
+
31
+ /** 光标左移一个 code point(surrogate pair 安全)。不超出 0。 */
32
+ export function moveCursorLeft(state: QuestionState): void {
33
+ const newLeft = state.cursorIndex - 1;
34
+ state.cursorIndex = newLeft > 0 && isHighSurrogate(state.draftText, newLeft - 1)
35
+ ? newLeft - 1
36
+ : Math.max(0, newLeft);
37
+ }
38
+
39
+ /** 光标右移一个 code point(surrogate pair 安全)。不超出 draftText.length。 */
40
+ export function moveCursorRight(state: QuestionState): void {
41
+ state.cursorIndex = isHighSurrogate(state.draftText, state.cursorIndex)
42
+ ? Math.min(state.draftText.length, state.cursorIndex + SURROGATE_PAIR_LEN)
43
+ : Math.min(state.draftText.length, state.cursorIndex + 1);
44
+ }
45
+
46
+ /** 光标移到行首。 */
47
+ export function moveCursorHome(state: QuestionState): void {
48
+ state.cursorIndex = 0;
49
+ }
50
+
51
+ /** 光标移到行尾。 */
52
+ export function moveCursorEnd(state: QuestionState): void {
53
+ state.cursorIndex = state.draftText.length;
54
+ }
55
+
56
+ /** 多字符粘贴 chunk → printable 提取。
57
+ * 排除 bracketed paste 标记,过滤未识别控制序列(OSC/DA/DCS/APC/unknown CSI)。
58
+ * 返回 true 表示有文本插入。
59
+ * data 整体性由 StdinBuffer 序列拆分保证。 */
60
+ export function handleEditorPaste(state: QuestionState, data: string): boolean {
61
+ if (data.startsWith("\x1b") && !data.includes("\x1b[200~") && !data.includes("\x1b[201~")) {
62
+ return false;
63
+ }
64
+ const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
65
+ let changed = false;
66
+ // 用 Array.from 正确拆分 emoji/surrogate pairs
67
+ for (const c of Array.from(cleaned)) {
68
+ if (c >= " ") {
69
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
70
+ state.cursorIndex += c.length;
71
+ changed = true;
72
+ }
73
+ }
74
+ return changed;
75
+ }