@zhushanwen/pi-ask-user 0.0.1

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.
@@ -0,0 +1,124 @@
1
+ // src/__tests__/validate.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import type { Question } from "../types";
5
+ import { validateInput } from "../validate";
6
+
7
+ const q = (overrides: Partial<Question> = {}): Question => ({
8
+ question: "Q1",
9
+ options: [{ label: "A" }, { label: "B" }],
10
+ ...overrides,
11
+ });
12
+
13
+ describe("validateInput", () => {
14
+ it("returns null for valid single question", () => {
15
+ expect(validateInput([q()])).toBeNull();
16
+ });
17
+
18
+ it("returns null for valid multiple questions with headers", () => {
19
+ expect(
20
+ validateInput([
21
+ q({ question: "Q1", header: "First" }),
22
+ q({ question: "Q2", header: "Second" }),
23
+ ]),
24
+ ).toBeNull();
25
+ });
26
+
27
+ it("returns null for single question without header", () => {
28
+ expect(validateInput([q()])).toBeNull();
29
+ });
30
+
31
+ it("detects duplicate question text", () => {
32
+ const result = validateInput([
33
+ q({ question: "Same", header: "A" }),
34
+ q({ question: "Same", header: "B" }),
35
+ ]);
36
+ expect(result).toContain("Duplicate question");
37
+ expect(result).toContain("Same");
38
+ });
39
+
40
+ it("detects duplicate option labels within a question", () => {
41
+ const result = validateInput([
42
+ q({ options: [{ label: "A" }, { label: "A" }] }),
43
+ ]);
44
+ expect(result).toContain("Duplicate option label");
45
+ expect(result).toContain("A");
46
+ });
47
+
48
+ it("detects missing header in multi-question", () => {
49
+ const result = validateInput([
50
+ q({ question: "Q1", header: "First" }),
51
+ q({ question: "Q2" }), // no header
52
+ ]);
53
+ expect(result).toContain("header");
54
+ expect(result).toContain("Q2");
55
+ });
56
+
57
+ it("allows whitespace-only header on single question (header unused)", () => {
58
+ const result = validateInput([
59
+ q({ question: "Q1", header: " " }),
60
+ ]);
61
+ expect(result).toBeNull();
62
+ });
63
+
64
+ it("detects empty-string header in multi-question", () => {
65
+ const result = validateInput([
66
+ q({ question: "Q1", header: "First" }),
67
+ q({ question: "Q2", header: " " }),
68
+ ]);
69
+ expect(result).toContain("header");
70
+ });
71
+
72
+ // V-9: 4 个问题上限
73
+ it("accepts 4 questions (maxItems boundary)", () => {
74
+ const result = validateInput([
75
+ q({ question: "Q1", header: "H1" }),
76
+ q({ question: "Q2", header: "H2" }),
77
+ q({ question: "Q3", header: "H3" }),
78
+ q({ question: "Q4", header: "H4" }),
79
+ ]);
80
+ expect(result).toBeNull();
81
+ });
82
+
83
+ // V-10: 不同 question 可以有相同 header(header 不要求唯一)
84
+ it("allows duplicate headers across different questions", () => {
85
+ const result = validateInput([
86
+ q({ question: "Q1", header: "Same" }),
87
+ q({ question: "Q2", header: "Same" }),
88
+ ]);
89
+ expect(result).toBeNull();
90
+ });
91
+
92
+ // V-11: option description 不参与唯一性校验
93
+ it("allows duplicate descriptions across options", () => {
94
+ const result = validateInput([
95
+ q({
96
+ options: [
97
+ { label: "A", description: "same desc" },
98
+ { label: "B", description: "same desc" },
99
+ ],
100
+ }),
101
+ ]);
102
+ expect(result).toBeNull();
103
+ });
104
+
105
+ // V-12: question 文本超过 QUESTION_MAX_CHARS(1000) 上限
106
+ it("rejects question text exceeding QUESTION_MAX_CHARS (1000)", () => {
107
+ const result = validateInput([q({ question: "x".repeat(1001) })]);
108
+ expect(result).not.toBeNull();
109
+ expect(result).toContain("1000");
110
+ });
111
+
112
+ // V-13: question 文本恰好 1000 字符(边界值,合法)
113
+ it("accepts question text at exactly QUESTION_MAX_CHARS (1000)", () => {
114
+ expect(validateInput([q({ question: "x".repeat(1000) })])).toBeNull();
115
+ });
116
+
117
+ // V-14: question 文本含控制字符(\n \t \r \x00)被拒绝
118
+ it("rejects question text containing control characters", () => {
119
+ for (const text of ["line1\nline2", "a\tb", "a\rb", "a\x00b"]) {
120
+ const result = validateInput([q({ question: text })]);
121
+ expect(result).toContain("control characters");
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,473 @@
1
+ // src/component.ts
2
+ import { type Component, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+
4
+ import { allOptions, renderQuestionView } from "./question-view";
5
+ import { buildResult, renderSubmitView } from "./submit-view";
6
+ import {
7
+ createQuestionState,
8
+ HEADER_MAX_CHARS,
9
+ type Question,
10
+ type QuestionState,
11
+ type Result,
12
+ type ThemeLike,
13
+ } from "./types";
14
+
15
+ // ── 组件私有类型(不跨模块共享,无需放 types.ts) ──
16
+
17
+ /** 最小 TUI 接口(满足真实 TUI 和测试 stub) */
18
+ export interface TUILike {
19
+ requestRender(): void;
20
+ }
21
+
22
+ /** box 边框左右各占用 1 列(`│` × 2) */
23
+ const BORDER_OVERHEAD = 2;
24
+
25
+ // ── AskUserComponent ─────────────────────────────────
26
+
27
+ export class AskUserComponent implements Component {
28
+ private questions: Question[];
29
+ private theme: ThemeLike;
30
+ private tui: TUILike;
31
+ private done: (result: Result | null) => void;
32
+
33
+ private states: QuestionState[];
34
+ private activeTab: number = 0;
35
+ private editorText: string = "";
36
+
37
+ /** Submit tab 上的左右焦点:默认 Submit;← / → 切换;Enter 触发当前项。
38
+ * 问题 tab 上无意义(仅视觉占位),不参与输入路由。 */
39
+ private submitTabFocus: "submit" | "cancel" = "submit";
40
+
41
+ /** Esc 在首个问题时进入「确认取消」覆盖层;Esc 再次确认取消,任意键退出覆盖层。 */
42
+ private pendingCancel: boolean = false;
43
+
44
+ private cachedWidth?: number;
45
+ private cachedLines?: string[];
46
+ private _resolved: boolean = false;
47
+
48
+ constructor(
49
+ questions: Question[],
50
+ tui: TUILike,
51
+ theme: ThemeLike,
52
+ done: (result: Result | null) => void,
53
+ ) {
54
+ this.questions = questions;
55
+ this.tui = tui;
56
+ this.theme = theme;
57
+ this.done = done;
58
+ this.states = questions.map(() => createQuestionState());
59
+ this.invalidate();
60
+ }
61
+
62
+ // ── 派生 ──
63
+ private get isSingle(): boolean {
64
+ return this.questions.length === 1;
65
+ }
66
+ private get totalTabs(): number {
67
+ return this.questions.length + 1;
68
+ }
69
+ private allConfirmed(): boolean {
70
+ return this.states.every((s) => s.confirmed);
71
+ }
72
+
73
+ invalidate(): void {
74
+ this.cachedWidth = undefined;
75
+ this.cachedLines = undefined;
76
+ }
77
+
78
+ // ── 渲染 ──
79
+ render(width: number): string[] {
80
+ if (this.cachedWidth === width && this.cachedLines) {
81
+ return this.cachedLines;
82
+ }
83
+ const t = this.theme;
84
+ // box 边框占用左右各 1 列;子视图在 innerWidth 下渲染
85
+ const innerWidth = Math.max(0, width - BORDER_OVERHEAD);
86
+
87
+ const inner: string[] = [];
88
+ const add = (s: string): void => {
89
+ inner.push(s);
90
+ };
91
+
92
+ if (!this.isSingle) {
93
+ this.renderTabBar(innerWidth, add);
94
+ inner.push("");
95
+ }
96
+
97
+ if (this.pendingCancel) {
98
+ // 确认取消覆盖层:替代当前 tab 内容
99
+ add(t.fg("warning", t.bold(" Cancel all questions?")));
100
+ inner.push("");
101
+ add(t.fg("text", " Your answers will be discarded."));
102
+ inner.push("");
103
+ add(t.fg("dim", " Esc confirm cancel · any other key to stay"));
104
+ } else if (this.activeTab >= this.questions.length) {
105
+ // Submit tab
106
+ for (const line of renderSubmitView(this.questions, this.states, t, innerWidth, this.submitTabFocus)) add(line);
107
+ } else {
108
+ const q = this.questions[this.activeTab]!;
109
+ const state = this.states[this.activeTab]!;
110
+ for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, this.editorText)) {
111
+ add(line);
112
+ }
113
+ }
114
+
115
+ // 多问题:底部按钮栏(Submit / Cancel)。Submit tab 上不重复渲染(renderSubmitView 内嵌 focus 高亮)
116
+ if (!this.isSingle && this.activeTab < this.questions.length) {
117
+ inner.push("");
118
+ this.renderButtonBar(innerWidth, add, null);
119
+ }
120
+
121
+ // 用 box 边框包裹:每行 pad 到 innerWidth 后加 │ 左右边框
122
+ const lines: string[] = [];
123
+ lines.push(t.fg("dim", `┌${"─".repeat(innerWidth)}┐`));
124
+ for (const line of inner) {
125
+ const padded = truncateToWidth(line, innerWidth, "", true);
126
+ lines.push(`${t.fg("dim", "│")}${padded}${t.fg("dim", "│")}`);
127
+ }
128
+ lines.push(t.fg("dim", `└${"─".repeat(innerWidth)}┘`));
129
+
130
+ this.cachedWidth = width;
131
+ this.cachedLines = lines;
132
+ return lines;
133
+ }
134
+
135
+ private renderTabBar(innerWidth: number, add: (s: string) => void): void {
136
+ const t = this.theme;
137
+ const parts: string[] = [" "];
138
+ for (let i = 0; i < this.questions.length; i++) {
139
+ const q = this.questions[i]!;
140
+ const s = this.states[i]!;
141
+ const isActive = i === this.activeTab;
142
+ const header = q.header?.slice(0, HEADER_MAX_CHARS) ?? "";
143
+ if (isActive) {
144
+ parts.push(t.bg("selectedBg", t.fg("text", ` ${header} `)));
145
+ } else if (s.confirmed) {
146
+ parts.push(t.fg("success", ` ✓${header} `));
147
+ } else {
148
+ parts.push(t.fg("muted", ` □${header} `));
149
+ }
150
+ // tab 之间竖线分割(最后一个 question tab 后由 Submit 分支补)
151
+ parts.push(t.fg("dim", "│"));
152
+ }
153
+ const isSubmit = this.activeTab === this.questions.length;
154
+ const submitLabel = " ✓ Submit ";
155
+ if (isSubmit) {
156
+ parts.push(t.bg("selectedBg", t.fg("text", submitLabel)));
157
+ } else if (this.allConfirmed()) {
158
+ parts.push(t.fg("success", submitLabel));
159
+ } else {
160
+ parts.push(t.fg("dim", submitLabel));
161
+ }
162
+ add(truncateToWidth(parts.join(""), innerWidth));
163
+ }
164
+
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
+ // ── 输入路由 ──
188
+ handleInput(data: string): void {
189
+ if (this._resolved) return;
190
+
191
+ // 确认取消覆盖层:Esc 确认取消,任意其他键退出覆盖层(留在表单)
192
+ if (this.pendingCancel) {
193
+ if (matchesKey(data, "escape")) {
194
+ this.cancel();
195
+ } else {
196
+ this.pendingCancel = false;
197
+ this.invalidate();
198
+ this.tui.requestRender();
199
+ }
200
+ return;
201
+ }
202
+
203
+ // Submit tab
204
+ if (!this.isSingle && this.activeTab === this.questions.length) {
205
+ this.handleSubmitTabInput(data);
206
+ return;
207
+ }
208
+
209
+ const state = this.states[this.activeTab]!;
210
+ const q = this.questions[this.activeTab]!;
211
+
212
+ // freeform / comment mode → editor text input
213
+ if (state.mode === "freeform" || state.mode === "comment") {
214
+ this.handleEditorInput(data, state, q);
215
+ return;
216
+ }
217
+
218
+ // Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
219
+ if (matchesKey(data, "escape")) {
220
+ this.escBackOrConfirm();
221
+ return;
222
+ }
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);
228
+ return;
229
+ }
230
+ if (!this.isSingle && matchesKey(data, "shift+tab")) {
231
+ this.gotoTab((this.activeTab - 1 + this.totalTabs) % this.totalTabs);
232
+ return;
233
+ }
234
+
235
+ if (matchesKey(data, "up")) {
236
+ state.cursorIndex = Math.max(0, state.cursorIndex - 1);
237
+ this.invalidate();
238
+ this.tui.requestRender();
239
+ return;
240
+ }
241
+ if (matchesKey(data, "down")) {
242
+ const max = allOptions(q).length - 1;
243
+ state.cursorIndex = Math.min(max, state.cursorIndex + 1);
244
+ this.invalidate();
245
+ this.tui.requestRender();
246
+ return;
247
+ }
248
+
249
+ const opts = allOptions(q);
250
+ const onOther = state.cursorIndex === opts.length - 1;
251
+
252
+ // Other row → Enter opens freeform editor(单选/多选统一入口)。
253
+ // 单选/多选普通选项的 Enter 各自有不同语义(确认/加入选中),与 Other 互斥:
254
+ // onOther 时本分支先消费 Enter,下面多选/单选分支不再处理。
255
+ if (onOther && matchesKey(data, "enter")) {
256
+ state.mode = "freeform";
257
+ this.editorText = state.freeTextValue ?? "";
258
+ this.invalidate();
259
+ this.tui.requestRender();
260
+ return;
261
+ }
262
+
263
+ if (q.multiSelect && !onOther) {
264
+ if (matchesKey(data, "space")) {
265
+ this.toggleIndex(state, state.cursorIndex);
266
+ return;
267
+ }
268
+ if (matchesKey(data, "enter")) {
269
+ // Enter 先把光标所在的普通选项加入选中再确认,与单选分支(Enter 时
270
+ // selectedIndex = cursorIndex)保持一致。即使 freeTextValue 已存在
271
+ // (Other 录入),也尊重「在此选项上按 Enter 想选中它」的意图,避免
272
+ // 静默用旧 Other 文本确认。add 幂等,不会误取消已选项。
273
+ state.selectedIndices.add(state.cursorIndex);
274
+ this.afterConfirm(state, q);
275
+ return;
276
+ }
277
+ } else if (!q.multiSelect && !onOther) {
278
+ if (matchesKey(data, "enter")) {
279
+ state.selectedIndex = state.cursorIndex;
280
+ state.freeTextValue = null;
281
+ this.afterConfirm(state, q);
282
+ return;
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Submit tab 输入路由:
289
+ * - ← / → → 切换 submitTabFocus(Submit ↔ Cancel 视觉高亮)
290
+ * - Enter → 触发当前 focus 项:Submit=allConfirmed 才提交;Cancel=直接取消
291
+ * - Tab / → → 环绕到首个问题
292
+ * - Esc / Shift+Tab → 回退到最后一个问题
293
+ *
294
+ * 注意:Submit tab 上 ←/→ 不再切回问题区(与问题 tab 上的"←/→ 不切 tab"对称,
295
+ * 避免在 Submit 视图内意外跳走)。
296
+ */
297
+ 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();
303
+ return;
304
+ }
305
+ // Esc / Shift+Tab → 回退到最后一个问题
306
+ if (matchesKey(data, "escape") || matchesKey(data, "shift+tab")) {
307
+ this.activeTab = this.questions.length - 1;
308
+ this.invalidate();
309
+ this.tui.requestRender();
310
+ return;
311
+ }
312
+ // Tab → 环绕到首个问题
313
+ if (matchesKey(data, "tab")) {
314
+ this.activeTab = 0;
315
+ this.invalidate();
316
+ this.tui.requestRender();
317
+ return;
318
+ }
319
+ // Enter → 触发当前 focus
320
+ if (matchesKey(data, "enter")) {
321
+ if (this.submitTabFocus === "submit") {
322
+ if (this.allConfirmed()) this.submit();
323
+ } else {
324
+ // Cancel on Submit tab:无需二次确认(已经在最"终点"),直接 cancel()
325
+ this.cancel();
326
+ }
327
+ return;
328
+ }
329
+ }
330
+
331
+ private handleEditorInput(data: string, state: QuestionState, q: Question): void {
332
+ if (matchesKey(data, "escape")) {
333
+ if (state.mode === "comment") {
334
+ // AC-17: Esc in comment = skip comment, advance (keep existing commentValue)
335
+ state.mode = "options";
336
+ this.editorText = "";
337
+ this.advance();
338
+ return;
339
+ }
340
+ // Esc in freeform editor = back to options (discard input)
341
+ state.mode = "options";
342
+ this.editorText = "";
343
+ this.invalidate();
344
+ this.tui.requestRender();
345
+ return;
346
+ }
347
+ if (matchesKey(data, "enter")) {
348
+ const text = this.editorText.trim();
349
+ if (state.mode === "freeform") {
350
+ if (text) {
351
+ state.freeTextValue = text;
352
+ state.selectedIndex = null;
353
+ state.mode = "options";
354
+ this.editorText = "";
355
+ // freeform 保存后走 afterConfirm(可能进 comment 模式)
356
+ this.afterConfirm(state, q);
357
+ } else {
358
+ // FR-6: 空 Enter 仅清除 freeTextValue、关闭编辑器回选项列表,不含确认语义(不置 confirmed)
359
+ state.freeTextValue = null;
360
+ state.mode = "options";
361
+ this.editorText = "";
362
+ // 对齐 toggleIndex 守卫:清空后若全无答案,重置 confirmed,维持 confirmed ⟹ 有答案 不变式
363
+ if ((q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) && state.freeTextValue === null) {
364
+ state.confirmed = false;
365
+ }
366
+ this.invalidate();
367
+ this.tui.requestRender();
368
+ }
369
+ return;
370
+ }
371
+ // comment mode:保存评论后直接前进(不再回头进 comment)
372
+ state.commentValue = text || null;
373
+ state.mode = "options";
374
+ this.editorText = "";
375
+ this.advance();
376
+ return;
377
+ }
378
+ if (matchesKey(data, "backspace")) {
379
+ this.editorText = this.editorText.slice(0, -1);
380
+ this.invalidate();
381
+ this.tui.requestRender();
382
+ return;
383
+ }
384
+ // Printable char
385
+ if (data.length === 1 && data >= " ") {
386
+ this.editorText += data;
387
+ this.invalidate();
388
+ this.tui.requestRender();
389
+ return;
390
+ }
391
+ }
392
+
393
+ private toggleIndex(state: QuestionState, index: number): void {
394
+ if (state.selectedIndices.has(index)) state.selectedIndices.delete(index);
395
+ else state.selectedIndices.add(index);
396
+ if (state.selectedIndices.size === 0 && state.freeTextValue === null) {
397
+ state.confirmed = false;
398
+ }
399
+ this.invalidate();
400
+ this.tui.requestRender();
401
+ }
402
+
403
+ private autoConfirmIfAnswered(): void {
404
+ const state = this.states[this.activeTab];
405
+ if (!state || state.confirmed) return;
406
+ const q = this.questions[this.activeTab]!;
407
+ const hasAnswer = q.multiSelect
408
+ ? state.selectedIndices.size > 0 || state.freeTextValue !== null
409
+ : state.freeTextValue !== null || state.selectedIndex !== null;
410
+ if (hasAnswer) state.confirmed = true;
411
+ }
412
+
413
+ /** 切到目标 tab:离开当前 tab 时 auto-confirm 已答问题,刷新视图。 */
414
+ private gotoTab(target: number): void {
415
+ this.autoConfirmIfAnswered();
416
+ this.activeTab = target;
417
+ this.invalidate();
418
+ this.tui.requestRender();
419
+ }
420
+
421
+ /** Esc 语义:有上一个 tab 则回退;已在首个(或单问题)则进入确认取消覆盖层。 */
422
+ private escBackOrConfirm(): void {
423
+ if (this.activeTab > 0) {
424
+ this.activeTab--;
425
+ this.invalidate();
426
+ this.tui.requestRender();
427
+ return;
428
+ }
429
+ this.pendingCancel = true;
430
+ this.invalidate();
431
+ this.tui.requestRender();
432
+ }
433
+
434
+ /** 选中确认后的处理:若 allowComment,进入评论模式(可重入编辑/清除已有评论);否则前进。 */
435
+ private afterConfirm(state: QuestionState, q: Question): void {
436
+ state.confirmed = true;
437
+ if (q.allowComment && state.mode !== "comment") {
438
+ // 进入评论输入行。预填已有评论,空 Enter=清除、新文本=覆盖(FR-4 item6)。
439
+ // 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
440
+ state.mode = "comment";
441
+ this.editorText = state.commentValue ?? "";
442
+ this.invalidate();
443
+ this.tui.requestRender();
444
+ return;
445
+ }
446
+ this.advance();
447
+ }
448
+
449
+ private advance(): void {
450
+ if (this.isSingle) {
451
+ this.submit();
452
+ return;
453
+ }
454
+ if (this.activeTab < this.questions.length - 1) {
455
+ this.activeTab++;
456
+ } else {
457
+ this.activeTab = this.questions.length;
458
+ }
459
+ this.invalidate();
460
+ this.tui.requestRender();
461
+ }
462
+
463
+ private submit(): void {
464
+ this._resolved = true;
465
+ this.done(buildResult(this.questions, this.states));
466
+ }
467
+
468
+ /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态) */
469
+ cancel(): void {
470
+ this._resolved = true;
471
+ this.done(null);
472
+ }
473
+ }