@zhushanwen/pi-ask-user 0.0.3 → 0.1.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 +3 -2
- package/src/__tests__/component-keymap.test.ts +553 -0
- package/src/__tests__/component.test.ts +81 -10
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +24 -1
- package/src/__tests__/question-view.test.ts +113 -17
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +14 -1
- package/src/__tests__/w2-draft-hint.test.ts +246 -0
- package/src/__tests__/w3-regression.test.ts +310 -0
- package/src/component.ts +160 -94
- package/src/index.ts +6 -4
- package/src/question-view.ts +80 -41
- package/src/submit-view.ts +17 -7
- package/src/types.ts +24 -2
- package/src/validate.ts +9 -1
package/src/question-view.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
isHighSurrogate,
|
|
5
6
|
OTHER_LABEL,
|
|
6
7
|
type Question,
|
|
7
8
|
type QuestionState,
|
|
@@ -9,9 +10,16 @@ import {
|
|
|
9
10
|
SPLIT_PANE_MIN_WIDTH,
|
|
10
11
|
SPLIT_PANE_RIGHT_MIN,
|
|
11
12
|
SPLIT_PANE_SEPARATOR,
|
|
13
|
+
SURROGATE_PAIR_LEN,
|
|
12
14
|
type ThemeLike,
|
|
13
15
|
} from "./types";
|
|
14
16
|
|
|
17
|
+
const SPLIT_PANE_LEFT_RATIO = 0.42;
|
|
18
|
+
const DESCRIPTION_INDENT_MULTI = 10;
|
|
19
|
+
const DESCRIPTION_INDENT_SINGLE = 8;
|
|
20
|
+
const PREVIEW_MIN_WIDTH = 10;
|
|
21
|
+
const QUESTION_TEXT_MARGIN = 2;
|
|
22
|
+
|
|
15
23
|
export interface DisplayOption {
|
|
16
24
|
label: string;
|
|
17
25
|
description?: string;
|
|
@@ -21,6 +29,21 @@ export interface DisplayOption {
|
|
|
21
29
|
/** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
|
|
22
30
|
const MAX_EDITOR_LINES = 5;
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
|
|
34
|
+
* surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
|
|
35
|
+
* 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
|
|
36
|
+
* 光标在文本末尾(超出范围)时反色高亮一个空格占位。
|
|
37
|
+
*/
|
|
38
|
+
function renderCursorText(text: string, cursorPos: number): string {
|
|
39
|
+
const before = text.slice(0, cursorPos);
|
|
40
|
+
// 光标在高代理前 → 反色高亮整个 surrogate pair
|
|
41
|
+
const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
|
|
42
|
+
const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
|
|
43
|
+
const after = text.slice(cursorPos + charLen);
|
|
44
|
+
return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
/**
|
|
25
48
|
* 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
|
|
26
49
|
* - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
|
|
@@ -29,7 +52,7 @@ const MAX_EDITOR_LINES = 5;
|
|
|
29
52
|
*
|
|
30
53
|
* @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
|
|
31
54
|
* @param lead 首行前缀(含选中标记 / 勾选框)
|
|
32
|
-
* @param content 待换行展示的已样式化文本(可含 ANSI +
|
|
55
|
+
* @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
|
|
33
56
|
* @param availWidth 单行可用宽度
|
|
34
57
|
* @param maxLines 最多行数
|
|
35
58
|
*/
|
|
@@ -71,7 +94,7 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
71
94
|
if (width < SPLIT_PANE_MIN_WIDTH) return null;
|
|
72
95
|
const available = width - SPLIT_PANE_SEPARATOR.length;
|
|
73
96
|
if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
|
|
74
|
-
const preferredLeft = Math.floor(available *
|
|
97
|
+
const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
|
|
75
98
|
const left = Math.max(
|
|
76
99
|
SPLIT_PANE_LEFT_MIN,
|
|
77
100
|
Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
|
|
@@ -81,8 +104,12 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
81
104
|
return { left, right };
|
|
82
105
|
}
|
|
83
106
|
|
|
107
|
+
/** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
|
|
108
|
+
* 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
|
|
109
|
+
const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
|
|
110
|
+
|
|
84
111
|
/** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
|
|
85
|
-
* freeform 模式下,Other 行**原地**变 [ ] <input
|
|
112
|
+
* freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
|
|
86
113
|
* 不再依赖 buildEditorBlock 的下方独立编辑块。 */
|
|
87
114
|
function buildOptionLines(
|
|
88
115
|
q: Question,
|
|
@@ -90,7 +117,7 @@ function buildOptionLines(
|
|
|
90
117
|
theme: ThemeLike,
|
|
91
118
|
width: number,
|
|
92
119
|
hideDescriptions: boolean,
|
|
93
|
-
|
|
120
|
+
draftText: string = "",
|
|
94
121
|
): string[] {
|
|
95
122
|
const t = theme;
|
|
96
123
|
const opts = allOptions(q);
|
|
@@ -101,30 +128,41 @@ function buildOptionLines(
|
|
|
101
128
|
|
|
102
129
|
for (let i = 0; i < opts.length; i++) {
|
|
103
130
|
const opt = opts[i]!;
|
|
104
|
-
|
|
131
|
+
// 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
|
|
132
|
+
const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
|
|
133
|
+
const isSelected = i === activeOptionCursor;
|
|
105
134
|
const isOther = opt.isOther === true;
|
|
106
135
|
const prefix = isSelected ? t.fg("accent", ">") : " ";
|
|
107
136
|
|
|
108
137
|
if (isOther) {
|
|
138
|
+
// 标记位宽度必须与普通选项一致,否则编号列错位:
|
|
139
|
+
// 单选 check = 1 列,多选 box = 3 列。
|
|
140
|
+
// 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
|
|
141
|
+
// 两种情况下 Other 编号都与普通选项错位。
|
|
109
142
|
if (state.mode === "freeform") {
|
|
110
|
-
|
|
111
|
-
// lead = "> [ ] ",编号 + 输入文本跟在框后,内容起始列与普通选项一致。
|
|
112
|
-
const box = q.multiSelect ? t.fg("dim", "[ ]") : " ";
|
|
143
|
+
const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
|
|
113
144
|
const num = i + 1;
|
|
114
|
-
const lead = `${prefix} ${
|
|
145
|
+
const lead = `${prefix} ${marker} `;
|
|
115
146
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
116
|
-
// 编号 +
|
|
117
|
-
const
|
|
147
|
+
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
148
|
+
const cursorText = renderCursorText(draftText, state.cursorIndex);
|
|
149
|
+
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
118
150
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
119
151
|
} else {
|
|
120
152
|
const hasFreeText = state.freeTextValue !== null;
|
|
121
|
-
const
|
|
122
|
-
|
|
153
|
+
const marker = q.multiSelect
|
|
154
|
+
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
|
|
155
|
+
: (hasFreeText ? t.fg("success", "✓") : " ");
|
|
156
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
123
157
|
const num = i + 1;
|
|
124
|
-
add(`${prefix} ${
|
|
158
|
+
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
125
159
|
if (hasFreeText) {
|
|
126
|
-
//
|
|
127
|
-
|
|
160
|
+
// 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
|
|
161
|
+
// 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
|
|
162
|
+
const numStr = `${num}.`;
|
|
163
|
+
const lead = " ".repeat(
|
|
164
|
+
visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
|
|
165
|
+
);
|
|
128
166
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
129
167
|
const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
|
|
130
168
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
@@ -137,7 +175,7 @@ function buildOptionLines(
|
|
|
137
175
|
const num = i + 1;
|
|
138
176
|
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
139
177
|
if (opt.description && !hideDescriptions) {
|
|
140
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
178
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
|
|
141
179
|
for (const line of wrapped) add(` ${line}`);
|
|
142
180
|
}
|
|
143
181
|
} else {
|
|
@@ -147,7 +185,7 @@ function buildOptionLines(
|
|
|
147
185
|
const num = i + 1;
|
|
148
186
|
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
149
187
|
if (opt.description && !hideDescriptions) {
|
|
150
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
188
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
|
|
151
189
|
for (const line of wrapped) add(` ${line}`);
|
|
152
190
|
}
|
|
153
191
|
}
|
|
@@ -176,19 +214,14 @@ function buildPreviewLines(
|
|
|
176
214
|
if (opt.description?.trim()) text += `\n\n${opt.description}`;
|
|
177
215
|
}
|
|
178
216
|
|
|
179
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(
|
|
217
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
|
|
180
218
|
const lines = wrapped.slice(0, maxLines);
|
|
181
219
|
if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
|
|
182
220
|
return lines;
|
|
183
221
|
}
|
|
184
222
|
|
|
185
223
|
/**
|
|
186
|
-
*
|
|
187
|
-
* isSingle: 单问题模式(无 Tab 提示)。
|
|
188
|
-
* editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
|
|
189
|
-
*/
|
|
190
|
-
/**
|
|
191
|
-
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
|
|
224
|
+
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
|
|
192
225
|
* buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
|
|
193
226
|
* comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
|
|
194
227
|
*/
|
|
@@ -196,7 +229,8 @@ function buildEditorBlock(
|
|
|
196
229
|
theme: ThemeLike,
|
|
197
230
|
width: number,
|
|
198
231
|
mode: "freeform" | "comment",
|
|
199
|
-
|
|
232
|
+
draftText: string,
|
|
233
|
+
cursorIndex?: number,
|
|
200
234
|
): string[] {
|
|
201
235
|
if (mode === "freeform") {
|
|
202
236
|
return [""];
|
|
@@ -209,12 +243,12 @@ function buildEditorBlock(
|
|
|
209
243
|
add("");
|
|
210
244
|
const prompt = t.fg("muted", " Your comment (optional):");
|
|
211
245
|
add(prompt);
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
add(` ${t.fg("
|
|
246
|
+
// 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
|
|
247
|
+
const pos = cursorIndex ?? draftText.length;
|
|
248
|
+
const cursorText = renderCursorText(draftText, pos);
|
|
249
|
+
add(` ${t.fg("text", cursorText)}`);
|
|
216
250
|
add("");
|
|
217
|
-
add(t.fg("dim",
|
|
251
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
218
252
|
return lines;
|
|
219
253
|
}
|
|
220
254
|
|
|
@@ -225,14 +259,14 @@ function buildSplitPane(
|
|
|
225
259
|
theme: ThemeLike,
|
|
226
260
|
split: { left: number; right: number },
|
|
227
261
|
width: number,
|
|
228
|
-
|
|
262
|
+
draftText: string = "",
|
|
229
263
|
): string[] {
|
|
230
264
|
const t = theme;
|
|
231
265
|
const lines: string[] = [];
|
|
232
266
|
const add = (s: string): void => {
|
|
233
267
|
lines.push(truncateToWidth(s, width));
|
|
234
268
|
};
|
|
235
|
-
const leftLines = buildOptionLines(q, state, theme, split.left, true,
|
|
269
|
+
const leftLines = buildOptionLines(q, state, theme, split.left, true, draftText);
|
|
236
270
|
const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
|
|
237
271
|
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
238
272
|
const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
@@ -244,13 +278,18 @@ function buildSplitPane(
|
|
|
244
278
|
return lines;
|
|
245
279
|
}
|
|
246
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 渲染单个问题视图(spec FR-4)。
|
|
283
|
+
* isSingle: 单问题模式(无 Tab 提示)。
|
|
284
|
+
* draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
|
|
285
|
+
*/
|
|
247
286
|
export function renderQuestionView(
|
|
248
287
|
q: Question,
|
|
249
288
|
state: QuestionState,
|
|
250
289
|
theme: ThemeLike,
|
|
251
290
|
width: number,
|
|
252
291
|
isSingle: boolean,
|
|
253
|
-
|
|
292
|
+
draftText: string,
|
|
254
293
|
): string[] {
|
|
255
294
|
const t = theme;
|
|
256
295
|
const lines: string[] = [];
|
|
@@ -260,13 +299,13 @@ export function renderQuestionView(
|
|
|
260
299
|
const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
|
|
261
300
|
|
|
262
301
|
// 问题文本(word-wrap)
|
|
263
|
-
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width -
|
|
302
|
+
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
|
|
264
303
|
for (const line of wrapped) add(line);
|
|
265
304
|
|
|
266
305
|
// 上下文(如有)
|
|
267
306
|
if (q.context?.trim()) {
|
|
268
307
|
divider();
|
|
269
|
-
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width -
|
|
308
|
+
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
|
|
270
309
|
for (const line of ctxWrapped) add(line);
|
|
271
310
|
}
|
|
272
311
|
|
|
@@ -284,24 +323,24 @@ export function renderQuestionView(
|
|
|
284
323
|
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
285
324
|
if (state.mode === "freeform" || state.mode === "comment") {
|
|
286
325
|
add("");
|
|
287
|
-
const optionLines = buildOptionLines(q, state, theme, width, false,
|
|
326
|
+
const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
|
|
288
327
|
for (const line of optionLines) add(line);
|
|
289
|
-
const editorBlock = buildEditorBlock(theme, width, state.mode,
|
|
328
|
+
const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
|
|
290
329
|
lines.push(...editorBlock);
|
|
291
330
|
if (state.mode === "freeform") {
|
|
292
331
|
// freeform 模式 help 行:光标锁在 Other 上,正在输入
|
|
293
|
-
add(t.fg("dim",
|
|
332
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
294
333
|
}
|
|
295
334
|
return lines;
|
|
296
335
|
}
|
|
297
336
|
|
|
298
337
|
if (!split) {
|
|
299
338
|
// 单列模式
|
|
300
|
-
const optionLines = buildOptionLines(q, state, theme, width, false,
|
|
339
|
+
const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
|
|
301
340
|
for (const line of optionLines) add(line);
|
|
302
341
|
} else {
|
|
303
342
|
// 分屏模式
|
|
304
|
-
lines.push(...buildSplitPane(q, state, theme, split, width,
|
|
343
|
+
lines.push(...buildSplitPane(q, state, theme, split, width, draftText));
|
|
305
344
|
}
|
|
306
345
|
|
|
307
346
|
add("");
|
package/src/submit-view.ts
CHANGED
|
@@ -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 表示未答。
|
|
@@ -84,13 +100,7 @@ export function renderSubmitView(
|
|
|
84
100
|
|
|
85
101
|
// 内嵌按钮栏:[ Submit ] [ Cancel ],根据 focus 高亮
|
|
86
102
|
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", "]")}`);
|
|
103
|
+
add(renderButtonBar(t, allDone, focus));
|
|
94
104
|
|
|
95
105
|
// Submit tab 帮助行
|
|
96
106
|
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]/;
|
|
@@ -57,5 +57,13 @@ export function validateInput(questions: Question[]): string | null {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
|
|
61
|
+
// 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
|
|
62
|
+
for (const q of questions) {
|
|
63
|
+
if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
|
|
64
|
+
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.`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
return null;
|
|
61
69
|
}
|