@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.
package/src/types.ts ADDED
@@ -0,0 +1,128 @@
1
+ // src/types.ts
2
+ import { type Static, Type } from "@sinclair/typebox";
3
+
4
+ // ── 常量 ─────────────────────────────────────────────
5
+ export const OTHER_LABEL = "Other";
6
+ export const HEADER_MAX_CHARS = 12;
7
+ /** question 文本长度上限:保证 details.answers 的 key 有界、可预测(spec FR-2) */
8
+ export const QUESTION_MAX_CHARS = 1000;
9
+ export const SPLIT_PANE_MIN_WIDTH = 84;
10
+ export const SPLIT_PANE_SEPARATOR = " │ ";
11
+ export const SPLIT_PANE_LEFT_MIN = 32;
12
+ export const SPLIT_PANE_RIGHT_MIN = 28;
13
+ export const ANSWER_COMMENT_SEPARATOR = " — ";
14
+
15
+ // ── Input schema(LLM 调用参数) ─────────────────────
16
+ // description 用英文:这些字符串会进 LLM 的 tool schema,英文更利于模型理解。
17
+ export const OptionSchema = Type.Object({
18
+ label: Type.String({
19
+ description:
20
+ "Short, mutually exclusive option label (also the answer value returned to the LLM — keep it concise, ≤ ~40 chars). To recommend an option, prefix its label with '(Recommended)' and list it first.",
21
+ }),
22
+ description: Type.Optional(
23
+ Type.String({
24
+ description:
25
+ "Short rationale shown under the label and in the split-pane preview. Helps the user decide — explain the tradeoff, don't restate the label.",
26
+ }),
27
+ ),
28
+ });
29
+
30
+ export const QuestionSchema = Type.Object({
31
+ question: Type.String({
32
+ description:
33
+ "Full question text. Must be one self-contained decision; avoid multi-part questions. Plain text only (no newlines or control characters).",
34
+ }),
35
+ header: Type.Optional(
36
+ Type.String({
37
+ description:
38
+ "Tab label, <=12 chars, required when questions.length > 1. Omit for a single question.",
39
+ }),
40
+ ),
41
+ context: Type.Optional(Type.String({ description: "Short context summary shown above the question. Pass what you learned from read/grep so the user can answer without re-explaining." })),
42
+ options: Type.Array(OptionSchema, {
43
+ minItems: 2,
44
+ maxItems: 4,
45
+ description: "2-4 mutually exclusive options. Each must be a defensible standalone answer; do NOT include an 'Other' option — it is added automatically.",
46
+ }),
47
+ multiSelect: Type.Optional(
48
+ Type.Boolean({ description: "Default false. Set true only when more than one option can validly apply simultaneously; otherwise leave false for a single best answer." }),
49
+ ),
50
+ allowComment: Type.Optional(
51
+ Type.Boolean({ description: "Default false. Set true to let the user append a short free-text comment after selecting (e.g. to note a constraint)." }),
52
+ ),
53
+ });
54
+
55
+ export const InputSchema = Type.Object({
56
+ questions: Type.Array(QuestionSchema, {
57
+ minItems: 1,
58
+ maxItems: 4,
59
+ description: "1-4 questions, each a single decision. Batch only related decisions that the user should resolve together; otherwise ask the most important one alone.",
60
+ }),
61
+ });
62
+
63
+ // ── 派生类型 ─────────────────────────────────────────
64
+ export type Option = Static<typeof OptionSchema>;
65
+ export type Question = Static<typeof QuestionSchema>;
66
+ export type Input = Static<typeof InputSchema>;
67
+
68
+ // ── Result schema(details,renderResult 数据源) ─────
69
+ export const ResultSchema = Type.Object({
70
+ questions: Type.Array(QuestionSchema),
71
+ answers: Type.Record(Type.String(), Type.String()),
72
+ cancelled: Type.Boolean(),
73
+ });
74
+
75
+ export type Result = Static<typeof ResultSchema>;
76
+
77
+ /** execute 意外异常时返回的错误 details(区别于 Result.cancelled 的业务取消) */
78
+ export interface ErrorDetails {
79
+ error: string;
80
+ }
81
+
82
+ /** execute 返回的 details 联合:正常/取消/校验失败走 Result,意外异常走 ErrorDetails */
83
+ export type AskUserDetails = Result | ErrorDetails;
84
+
85
+ // ── 跨模块共享的交互状态类型 ─────────────────────────
86
+ // 放这里(而非 component.ts)是为了让 question-view.ts / submit-view.ts
87
+ // 这两个纯渲染函数只依赖 types.ts,不反向依赖 component.ts(消除循环依赖)。
88
+
89
+ /** TUI 颜色主题的最小接口(满足真实 Theme 和测试 stub) */
90
+ export interface ThemeLike {
91
+ fg(token: string, text: string): string;
92
+ bg(token: string, text: string): string;
93
+ bold(text: string): string;
94
+ }
95
+
96
+ /** 单问题的交互模式 */
97
+ export type QuestionMode = "options" | "freeform" | "comment";
98
+
99
+ /** 单问题的交互状态(每问题一个实例) */
100
+ export interface QuestionState {
101
+ /** 光标位置(高亮的选项,≠ 已选答案) */
102
+ cursorIndex: number;
103
+ /** 单选:显式选中的选项 index;null=未选 */
104
+ selectedIndex: number | null;
105
+ /** 多选:已 toggle 的选项 index 集合 */
106
+ selectedIndices: Set<number>;
107
+ /** 是否已确认(Submit 门的条件) */
108
+ confirmed: boolean;
109
+ /** Other 自由文本答案;null=未输入 */
110
+ freeTextValue: string | null;
111
+ /** 可选评论;null=未输入 */
112
+ commentValue: string | null;
113
+ /** 当前交互模式 */
114
+ mode: QuestionMode;
115
+ }
116
+
117
+ /** 创建初始 QuestionState */
118
+ export function createQuestionState(): QuestionState {
119
+ return {
120
+ cursorIndex: 0,
121
+ selectedIndex: null,
122
+ selectedIndices: new Set<number>(),
123
+ confirmed: false,
124
+ freeTextValue: null,
125
+ commentValue: null,
126
+ mode: "options",
127
+ };
128
+ }
@@ -0,0 +1,61 @@
1
+ // src/validate.ts
2
+ import { type Question,QUESTION_MAX_CHARS } from "./types";
3
+
4
+ /** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
5
+ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
6
+
7
+ /**
8
+ * 校验输入参数。通过返回 null,失败返回错误消息字符串。
9
+ * 校验项(spec FR-2):
10
+ * - question 文本长度上限与无控制字符(保证 answers key 有界、可预测)
11
+ * - question 文本在数组内唯一
12
+ * - 同问题内 option label 唯一
13
+ * - 多问题(questions.length > 1)时每个 question 必须有非空 header
14
+ *
15
+ * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改)。
16
+ */
17
+ export function validateInput(questions: Question[]): string | null {
18
+ const seenQuestions = new Set<string>();
19
+
20
+ for (const q of questions) {
21
+ const qt = q.question;
22
+
23
+ // 1a. question 文本长度上限(key 有界)
24
+ if (qt.length > QUESTION_MAX_CHARS) {
25
+ return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, 20)}...". Shorten it to a single concise decision; move extra context into the context field.`;
26
+ }
27
+ // 1b. question 文本无控制字符(key 可预测,不影响下游渲染/解析)
28
+ if (CONTROL_CHAR_RE.test(qt)) {
29
+ return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, 20)}...". Use plain single-line text; split multi-part questions into separate entries.`;
30
+ }
31
+
32
+ // 1c. question 文本唯一
33
+ if (seenQuestions.has(qt)) {
34
+ return `Duplicate question: "${qt}". Each question text must be unique; merge duplicates or rephrase one to differ.`;
35
+ }
36
+ seenQuestions.add(qt);
37
+
38
+ // 2. option label 唯一且非空(空 label 会污染 details.answers 的值)
39
+ const seenLabels = new Set<string>();
40
+ for (const opt of q.options) {
41
+ if (opt.label.trim() === "") {
42
+ return `Option label must not be empty in question "${q.question}". Give every option a distinct, descriptive label.`;
43
+ }
44
+ if (seenLabels.has(opt.label)) {
45
+ return `Duplicate option label "${opt.label}" in question "${q.question}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
46
+ }
47
+ seenLabels.add(opt.label);
48
+ }
49
+ }
50
+
51
+ // 3. 多问题时 header 必填且非空
52
+ if (questions.length > 1) {
53
+ for (const q of questions) {
54
+ if (!q.header || q.header.trim() === "") {
55
+ return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars.`;
56
+ }
57
+ }
58
+ }
59
+
60
+ return null;
61
+ }