@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/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
package/src/submit-view.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/submit-view.ts
|
|
2
2
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
|
+
import { formatAnswer } from "./answer-format";
|
|
4
5
|
import {
|
|
5
|
-
ANSWER_COMMENT_SEPARATOR,
|
|
6
6
|
HEADER_MAX_CHARS,
|
|
7
7
|
type Question,
|
|
8
8
|
type QuestionState,
|
|
@@ -10,6 +10,22 @@ import {
|
|
|
10
10
|
type ThemeLike,
|
|
11
11
|
} from "./types";
|
|
12
12
|
|
|
13
|
+
/** 渲染 [ Submit ] [ Cancel ] 按钮栏,返回带样式的单行字符串。
|
|
14
|
+
* focus: null=纯展示(问题 tab footer),"submit"/"cancel"=高亮对应按钮(Submit tab)。
|
|
15
|
+
* 统一了 component.ts footer 版与 submit-view 内嵌版的样式逻辑。 */
|
|
16
|
+
export function renderButtonBar(theme: ThemeLike, allDone: boolean, focus: "submit" | "cancel" | null): string {
|
|
17
|
+
const t = theme;
|
|
18
|
+
const isSubmit = focus === "submit";
|
|
19
|
+
const isCancel = focus === "cancel";
|
|
20
|
+
const submit = isSubmit
|
|
21
|
+
? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
|
|
22
|
+
: (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
|
|
23
|
+
const cancel = isCancel
|
|
24
|
+
? t.fg("accent", t.bold(" Cancel "))
|
|
25
|
+
: t.fg("muted", " Cancel ");
|
|
26
|
+
return `${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* 获取单问题的答案文本(供 Submit tab 显示)。
|
|
15
31
|
* 返回 null 表示未答。
|
|
@@ -28,9 +44,7 @@ export function getAnswerText(q: Question, s: QuestionState): string | null {
|
|
|
28
44
|
if (label) parts.push(label);
|
|
29
45
|
}
|
|
30
46
|
if (s.freeTextValue !== null) parts.push(s.freeTextValue);
|
|
31
|
-
|
|
32
|
-
const base = parts.join(", ");
|
|
33
|
-
return s.commentValue ? `${base}${ANSWER_COMMENT_SEPARATOR}${s.commentValue}` : base;
|
|
47
|
+
return formatAnswer(parts, s.commentValue);
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
/**
|
|
@@ -84,13 +98,7 @@ export function renderSubmitView(
|
|
|
84
98
|
|
|
85
99
|
// 内嵌按钮栏:[ Submit ] [ Cancel ],根据 focus 高亮
|
|
86
100
|
add("");
|
|
87
|
-
|
|
88
|
-
const isCancel = focus === "cancel";
|
|
89
|
-
const submitBtn = isSubmit
|
|
90
|
-
? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
|
|
91
|
-
: (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
|
|
92
|
-
const cancelBtn = isCancel ? t.fg("accent", t.bold(" Cancel ")) : t.fg("muted", " Cancel ");
|
|
93
|
-
add(`${t.fg("dim", "[")}${submitBtn}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancelBtn}${t.fg("dim", "]")}`);
|
|
101
|
+
add(renderButtonBar(t, allDone, focus));
|
|
94
102
|
|
|
95
103
|
// Submit tab 帮助行
|
|
96
104
|
add("");
|
package/src/types.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const OptionSchema = Type.Object({
|
|
|
30
30
|
export const QuestionSchema = Type.Object({
|
|
31
31
|
question: Type.String({
|
|
32
32
|
description:
|
|
33
|
-
"Full question text. Must be one self-contained decision; avoid multi-part questions.
|
|
33
|
+
"Full question text. Must be one self-contained decision; avoid multi-part questions. ≤1000 chars; plain single-line text only (no newlines or control characters).",
|
|
34
34
|
}),
|
|
35
35
|
header: Type.Optional(
|
|
36
36
|
Type.String({
|
|
@@ -63,7 +63,6 @@ export const InputSchema = Type.Object({
|
|
|
63
63
|
// ── 派生类型 ─────────────────────────────────────────
|
|
64
64
|
export type Option = Static<typeof OptionSchema>;
|
|
65
65
|
export type Question = Static<typeof QuestionSchema>;
|
|
66
|
-
export type Input = Static<typeof InputSchema>;
|
|
67
66
|
|
|
68
67
|
// ── Result schema(details,renderResult 数据源) ─────
|
|
69
68
|
export const ResultSchema = Type.Object({
|
|
@@ -108,10 +107,17 @@ export interface QuestionState {
|
|
|
108
107
|
confirmed: boolean;
|
|
109
108
|
/** Other 自由文本答案;null=未输入 */
|
|
110
109
|
freeTextValue: string | null;
|
|
110
|
+
/** freeform Esc 保存的未提交草稿;null=无草稿。
|
|
111
|
+
* 与 freeTextValue(已提交答案)分离,避免放弃的草稿污染答案、触发 auto-confirm。 */
|
|
112
|
+
freeDraft: string | null;
|
|
111
113
|
/** 可选评论;null=未输入 */
|
|
112
114
|
commentValue: string | null;
|
|
113
115
|
/** 当前交互模式 */
|
|
114
116
|
mode: QuestionMode;
|
|
117
|
+
/** 编辑器草稿文本(每问题独立持有,进编辑器时预填、退出时清空) */
|
|
118
|
+
draftText: string;
|
|
119
|
+
/** 进入编辑器前保存的 options 光标位置(退出编辑器时恢复) */
|
|
120
|
+
savedOptionsCursorIndex: number;
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
/** 创建初始 QuestionState */
|
|
@@ -122,7 +128,23 @@ export function createQuestionState(): QuestionState {
|
|
|
122
128
|
selectedIndices: new Set<number>(),
|
|
123
129
|
confirmed: false,
|
|
124
130
|
freeTextValue: null,
|
|
131
|
+
freeDraft: null,
|
|
125
132
|
commentValue: null,
|
|
126
133
|
mode: "options",
|
|
134
|
+
draftText: "",
|
|
135
|
+
savedOptionsCursorIndex: 0,
|
|
127
136
|
};
|
|
128
137
|
}
|
|
138
|
+
|
|
139
|
+
// ── UTF-16 surrogate pair 工具(编辑器光标移动/删除/渲染共用) ──
|
|
140
|
+
/** 高代理位掩码:charCode & 0xFC00 === 0xD800 判定 surrogate pair 前半 */
|
|
141
|
+
export const SURROGATE_HIGH_MASK = 0xFC00;
|
|
142
|
+
/** 高代理起始码点(surrogate pair 前半的判定值) */
|
|
143
|
+
export const SURROGATE_HIGH_START = 0xD800;
|
|
144
|
+
/** 一个 surrogate pair 占用的 UTF-16 code unit 数 */
|
|
145
|
+
export const SURROGATE_PAIR_LEN = 2;
|
|
146
|
+
|
|
147
|
+
/** 检查 s[i] 是否是 UTF-16 高代理(surrogate pair 的前半部分) */
|
|
148
|
+
export function isHighSurrogate(s: string, i: number): boolean {
|
|
149
|
+
return (s.charCodeAt(i) & SURROGATE_HIGH_MASK) === SURROGATE_HIGH_START;
|
|
150
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/validate.ts
|
|
2
|
-
import { type Question,QUESTION_MAX_CHARS } from "./types";
|
|
2
|
+
import { HEADER_MAX_CHARS, type Question, QUESTION_MAX_CHARS } from "./types";
|
|
3
3
|
|
|
4
4
|
/** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
|
|
5
5
|
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
|
|
@@ -55,6 +55,25 @@ export function validateInput(questions: Question[]): string | null {
|
|
|
55
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
56
|
}
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
// S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
|
|
60
|
+
// 后一个 question 的 __other/__comment 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
|
|
61
|
+
const seenHeaders = new Set<string>();
|
|
62
|
+
for (const q of questions) {
|
|
63
|
+
const h = q.header!.trim();
|
|
64
|
+
if (seenHeaders.has(h)) {
|
|
65
|
+
return `Duplicate header "${h}" in questions. Headers must be unique in multi-question mode — shared headers cause answer key collisions (one question's Other/comment overwrites another's). Rephrase one header to differ.`;
|
|
66
|
+
}
|
|
67
|
+
seenHeaders.add(h);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
|
|
72
|
+
// 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
|
|
73
|
+
for (const q of questions) {
|
|
74
|
+
if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
|
|
75
|
+
return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, 20)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
|
|
76
|
+
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
return null;
|