lite-questionnaire 1.0.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/LICENSE +21 -0
- package/README.md +358 -0
- package/core.ts +477 -0
- package/design/questionnaire-openapi.yaml +522 -0
- package/index.ts +396 -0
- package/input.ts +513 -0
- package/modules/confirm.ts +42 -0
- package/modules/multiSelect.ts +100 -0
- package/modules/rating.ts +105 -0
- package/modules/select.ts +118 -0
- package/modules/shared.ts +10 -0
- package/modules/text.ts +71 -0
- package/package.json +39 -0
- package/render.ts +319 -0
- package/skills/lite-questionnaire/SKILL.md +276 -0
- package/state.ts +39 -0
- package/types.ts +137 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 评分滑块问题模块
|
|
3
|
+
*
|
|
4
|
+
* 数字标尺 + 表情量表 + 下置光标,编辑态 ← → 调整,Enter 确认。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { Core } from "../core";
|
|
9
|
+
import type { ThemeLike } from "./shared";
|
|
10
|
+
|
|
11
|
+
/** 5 级表情映射 */
|
|
12
|
+
const EMOJI_MAP: Record<number, string> = {
|
|
13
|
+
1: "😡",
|
|
14
|
+
2: "😟",
|
|
15
|
+
3: "😐",
|
|
16
|
+
4: "😊",
|
|
17
|
+
5: "😍",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 根据评分值获取表情
|
|
22
|
+
*/
|
|
23
|
+
function getEmoji(value: number): string {
|
|
24
|
+
return EMOJI_MAP[value];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 渲染评分滑块问题
|
|
29
|
+
*/
|
|
30
|
+
export function renderRatingQuestion(
|
|
31
|
+
core: Core,
|
|
32
|
+
width: number,
|
|
33
|
+
theme: ThemeLike,
|
|
34
|
+
): string[] {
|
|
35
|
+
const q = core.currentQuestion();
|
|
36
|
+
if (!q || q.type !== "rating") return [];
|
|
37
|
+
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
const state = core.getUIState();
|
|
40
|
+
const current = state.ratingValue;
|
|
41
|
+
|
|
42
|
+
const indent = " ";
|
|
43
|
+
const values = [1, 2, 3, 4, 5];
|
|
44
|
+
const currentIndex = current - 1;
|
|
45
|
+
const numberLabels = ["1", "2", "3", "4", "5"];
|
|
46
|
+
const emojiLabels = values.map((v) => getEmoji(v));
|
|
47
|
+
const maxCellWidth = Math.max(
|
|
48
|
+
1,
|
|
49
|
+
...numberLabels.map((label) => visibleWidth(label)),
|
|
50
|
+
...(q.showEmoji ? emojiLabels.map((label) => visibleWidth(label)) : []),
|
|
51
|
+
);
|
|
52
|
+
const slotWidth = Math.max(3, maxCellWidth + 1);
|
|
53
|
+
const padSlot = (text: string): string => {
|
|
54
|
+
const padding = Math.max(0, slotWidth - visibleWidth(text));
|
|
55
|
+
const left = Math.floor(padding / 2);
|
|
56
|
+
const right = padding - left;
|
|
57
|
+
return " ".repeat(left) + text + " ".repeat(right);
|
|
58
|
+
};
|
|
59
|
+
const buildSlotLine = (labels: string[]): string => {
|
|
60
|
+
let line = "";
|
|
61
|
+
labels.forEach((label, index) => {
|
|
62
|
+
const cell = padSlot(label);
|
|
63
|
+
line += index === currentIndex ? theme.fg("accent", cell) : cell;
|
|
64
|
+
});
|
|
65
|
+
return line;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 数字标尺行
|
|
69
|
+
const numberLine = buildSlotLine(numberLabels);
|
|
70
|
+
lines.push(truncateToWidth(indent + numberLine, width));
|
|
71
|
+
|
|
72
|
+
// 表情行
|
|
73
|
+
if (q.showEmoji) {
|
|
74
|
+
lines.push(truncateToWidth(indent + buildSlotLine(emojiLabels), width));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 下置光标行:与数字/表情共用同一 slot 布局
|
|
78
|
+
const cursorLine = buildSlotLine(values.map((_, index) => (index === currentIndex ? "▲" : "")));
|
|
79
|
+
lines.push(truncateToWidth(indent + cursorLine, width));
|
|
80
|
+
|
|
81
|
+
const barWidth = visibleWidth(numberLine);
|
|
82
|
+
|
|
83
|
+
// 文字注释行
|
|
84
|
+
if (q.annotations) {
|
|
85
|
+
const minAnnot = q.annotations["1"];
|
|
86
|
+
const maxAnnot = q.annotations["5"];
|
|
87
|
+
const currentAnnot = q.annotations[String(current)];
|
|
88
|
+
let annotLine = "";
|
|
89
|
+
if (minAnnot && maxAnnot) {
|
|
90
|
+
annotLine = `${indent}${theme.fg("dim", minAnnot)}${" ".repeat(Math.max(0, barWidth - visibleWidth(minAnnot) - visibleWidth(maxAnnot)))}${theme.fg("dim", maxAnnot)}`;
|
|
91
|
+
}
|
|
92
|
+
if (annotLine) {
|
|
93
|
+
lines.push(truncateToWidth(annotLine, width));
|
|
94
|
+
}
|
|
95
|
+
if (currentAnnot) {
|
|
96
|
+
lines.push(truncateToWidth(` ${theme.fg("muted", "当前:")} ${theme.fg("accent", String(current))} - ${theme.fg("text", currentAnnot)}`, width));
|
|
97
|
+
} else {
|
|
98
|
+
lines.push(truncateToWidth(` ${theme.fg("muted", "当前:")} ${theme.fg("accent", String(current))}`, width));
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
lines.push(truncateToWidth(` ${theme.fg("muted", "当前:")} ${theme.fg("accent", String(current))}`, width));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单选问题模块
|
|
3
|
+
*
|
|
4
|
+
* Space 标定一项 + Enter 提交。
|
|
5
|
+
* 也处理 ← → 切换问题、↑ ↓ 导航选项的数字键快捷跳转。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { Editor } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { Core } from "../core";
|
|
11
|
+
import { getOptionsWithCustom } from "../render";
|
|
12
|
+
import type { ThemeLike } from "./shared";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 渲染单选问题的选项列表
|
|
16
|
+
*/
|
|
17
|
+
export function renderSelectOptions(
|
|
18
|
+
core: Core,
|
|
19
|
+
width: number,
|
|
20
|
+
theme: ThemeLike,
|
|
21
|
+
inputMode: boolean,
|
|
22
|
+
): string[] {
|
|
23
|
+
const q = core.currentQuestion();
|
|
24
|
+
if (!q || (q.type !== "select" && q.type !== "multiSelect")) return [];
|
|
25
|
+
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
const state = core.getUIState();
|
|
28
|
+
const opts = getOptionsWithCustom(q);
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < opts.length; i++) {
|
|
31
|
+
const opt = opts[i];
|
|
32
|
+
const isCursor = i === state.optionIndex;
|
|
33
|
+
const hasCustomText = opt.isCustom && state.customText !== null;
|
|
34
|
+
const isSelected = state.selectedIndices.includes(i);
|
|
35
|
+
|
|
36
|
+
// 前缀
|
|
37
|
+
let prefix: string;
|
|
38
|
+
if (isCursor) {
|
|
39
|
+
prefix = theme.fg("accent", "> ");
|
|
40
|
+
} else if (isSelected) {
|
|
41
|
+
prefix = theme.fg("success", "• ");
|
|
42
|
+
} else {
|
|
43
|
+
prefix = " ";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 颜色
|
|
47
|
+
const color = isCursor ? "accent" : isSelected ? "success" : "text";
|
|
48
|
+
|
|
49
|
+
// 标签
|
|
50
|
+
let displayLabel = opt.label;
|
|
51
|
+
if (hasCustomText) {
|
|
52
|
+
displayLabel = `"${state.customText}"`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 编辑标记
|
|
56
|
+
let suffix = "";
|
|
57
|
+
if (opt.isCustom && inputMode && isCursor) {
|
|
58
|
+
suffix = theme.fg("accent", " ✎");
|
|
59
|
+
} else if (opt.isCustom && isSelected) {
|
|
60
|
+
suffix = theme.fg("success", " ✎");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const num = `${i + 1}`;
|
|
64
|
+
let line = prefix + theme.fg(color, `${num}. ${displayLabel}`) + suffix;
|
|
65
|
+
|
|
66
|
+
lines.push(truncateToWidth(" " + line, width));
|
|
67
|
+
|
|
68
|
+
if (opt.description) {
|
|
69
|
+
lines.push(truncateToWidth(" " + theme.fg("muted", opt.description), width));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 单选输入处理。
|
|
78
|
+
* 返回 true 表示输入被处理,false 表示需要上游继续处理。
|
|
79
|
+
*/
|
|
80
|
+
export function handleSelectInput(
|
|
81
|
+
core: Core,
|
|
82
|
+
data: string,
|
|
83
|
+
_editor: Editor,
|
|
84
|
+
_theme: ThemeLike,
|
|
85
|
+
): boolean {
|
|
86
|
+
const q = core.currentQuestion();
|
|
87
|
+
if (!q) return false;
|
|
88
|
+
if (q.type !== "select") return false;
|
|
89
|
+
|
|
90
|
+
const state = core.getUIState();
|
|
91
|
+
const opts = getOptionsWithCustom(q);
|
|
92
|
+
|
|
93
|
+
// ↑ ↓ 导航
|
|
94
|
+
if (matchesKey(data, Key.up)) {
|
|
95
|
+
state.optionIndex = Math.max(0, state.optionIndex - 1);
|
|
96
|
+
core.saveUIState(state);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (matchesKey(data, Key.down)) {
|
|
100
|
+
state.optionIndex = Math.min(opts.length - 1, state.optionIndex + 1);
|
|
101
|
+
core.saveUIState(state);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 数字键 1-9 快捷跳转
|
|
106
|
+
const numMatch = /^[1-9]$/.exec(data);
|
|
107
|
+
if (numMatch) {
|
|
108
|
+
const target = parseInt(numMatch[0], 10) - 1;
|
|
109
|
+
if (target < opts.length) {
|
|
110
|
+
state.optionIndex = target;
|
|
111
|
+
core.saveUIState(state);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
}
|
package/modules/text.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本输入问题模块
|
|
3
|
+
*
|
|
4
|
+
* 默认显示只读摘要;按 Tab 进入内联编辑。
|
|
5
|
+
* 编辑态 Enter 提交并前进,Esc 退出编辑。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { Editor } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { Core } from "../core";
|
|
11
|
+
import type { ThemeLike } from "./shared";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 渲染文本输入问题(内联编辑器)
|
|
15
|
+
*/
|
|
16
|
+
export function renderTextQuestion(
|
|
17
|
+
core: Core,
|
|
18
|
+
width: number,
|
|
19
|
+
theme: ThemeLike,
|
|
20
|
+
editor: Editor,
|
|
21
|
+
): string[] {
|
|
22
|
+
const q = core.currentQuestion();
|
|
23
|
+
if (!q || q.type !== "text") return [];
|
|
24
|
+
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
const placeholder = q.placeholder || "输入内容...";
|
|
27
|
+
|
|
28
|
+
lines.push(" " + theme.fg("muted", "你的回答:"));
|
|
29
|
+
lines.push("");
|
|
30
|
+
|
|
31
|
+
if (!core.inputMode) {
|
|
32
|
+
const state = core.getUIState();
|
|
33
|
+
const existing = core.answers.get(q.id);
|
|
34
|
+
const text = existing && 'text' in existing ? existing.text : state.textDraft;
|
|
35
|
+
lines.push(" " + theme.fg("dim", text ? `> ${text}` : `> ${placeholder}`));
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 编辑态渲染编辑器内容
|
|
40
|
+
const editorLines = editor.render(width - 2);
|
|
41
|
+
if (editorLines.length === 0) {
|
|
42
|
+
lines.push(" " + theme.fg("dim", `> ${placeholder}`));
|
|
43
|
+
} else {
|
|
44
|
+
for (const line of editorLines) {
|
|
45
|
+
lines.push(" " + line);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 进入文本编辑模式 - 恢复草稿并激活编辑器
|
|
54
|
+
*/
|
|
55
|
+
export function enterTextEdit(core: Core, editor: Editor): void {
|
|
56
|
+
const state = core.getUIState();
|
|
57
|
+
const q = core.currentQuestion();
|
|
58
|
+
const existing = q ? core.answers.get(q.id) : undefined;
|
|
59
|
+
const existingText = existing && 'text' in existing ? existing.text : "";
|
|
60
|
+
editor.setText(state.textDraft || existingText || "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 离开文本编辑模式 - 保存草稿
|
|
65
|
+
*/
|
|
66
|
+
export function saveTextDraft(core: Core, editor: Editor): void {
|
|
67
|
+
const text = editor.getText();
|
|
68
|
+
const state = core.getUIState();
|
|
69
|
+
state.textDraft = text;
|
|
70
|
+
core.saveUIState(state);
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lite-questionnaire",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "轻量级交互式问卷工具 — 单选、多选、文本、确认、评分,带条件子问题和声明式校验",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"questionnaire",
|
|
8
|
+
"form",
|
|
9
|
+
"survey",
|
|
10
|
+
"interactive",
|
|
11
|
+
"tui"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "pi",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/yunwuhai/lite-questionnaire.git"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./index.ts"]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
+
"@earendil-works/pi-tui": "*",
|
|
25
|
+
"typebox": "*"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.ts",
|
|
29
|
+
"core.ts",
|
|
30
|
+
"input.ts",
|
|
31
|
+
"render.ts",
|
|
32
|
+
"state.ts",
|
|
33
|
+
"types.ts",
|
|
34
|
+
"modules/",
|
|
35
|
+
"design/",
|
|
36
|
+
"skills/",
|
|
37
|
+
"README.md"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/render.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questionnaire 通用渲染函数
|
|
3
|
+
*
|
|
4
|
+
* 面板框架、Tab 栏(含进度点)、提交汇总页、提示栏等。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EditorTheme } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Editor } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { Core } from "./core";
|
|
11
|
+
import type { Answer, FlatQuestion, ProgressColor } from "./types";
|
|
12
|
+
|
|
13
|
+
// ─── Editor 工厂 ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** 创建编辑器实例 */
|
|
16
|
+
export function createEditor(tui: unknown, editorTheme: EditorTheme): Editor {
|
|
17
|
+
// tui 参数类型为 any 以避免循环依赖
|
|
18
|
+
return new Editor(tui as Parameters<typeof Editor>[0], editorTheme);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── 面板 ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** 面板顶部边框线 */
|
|
24
|
+
export function panelTop(width: number, theme: { fg: (c: string, t: string) => string }): string {
|
|
25
|
+
return theme.fg("accent", "┌" + "─".repeat(width - 2) + "┐");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 面板底部边框线 */
|
|
29
|
+
export function panelBottom(width: number, theme: { fg: (c: string, t: string) => string }): string {
|
|
30
|
+
return theme.fg("accent", "└" + "─".repeat(width - 2) + "┘");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 面板内边距行 */
|
|
34
|
+
export function panelLine(text: string, width: number): string {
|
|
35
|
+
const innerWidth = Math.max(0, width - 4);
|
|
36
|
+
const clipped = truncateToWidth(text, innerWidth, "");
|
|
37
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
38
|
+
return "│ " + clipped + padding + " │";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── 进度点 ─────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** 将进度颜色映射为主题颜色 key + 显示字符 */
|
|
44
|
+
function progressGlyph(color: ProgressColor): string {
|
|
45
|
+
switch (color) {
|
|
46
|
+
case "green":
|
|
47
|
+
return "●";
|
|
48
|
+
case "red":
|
|
49
|
+
return "●";
|
|
50
|
+
case "none":
|
|
51
|
+
return "○";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 获取进度点的显示文本(含颜色) */
|
|
56
|
+
export function progressDot(
|
|
57
|
+
color: ProgressColor,
|
|
58
|
+
theme: { fg: (c: string, t: string) => string },
|
|
59
|
+
): string {
|
|
60
|
+
const glyph = progressGlyph(color);
|
|
61
|
+
switch (color) {
|
|
62
|
+
case "green":
|
|
63
|
+
return theme.fg("success", glyph);
|
|
64
|
+
case "red":
|
|
65
|
+
return theme.fg("error", glyph);
|
|
66
|
+
case "none":
|
|
67
|
+
return theme.fg("dim", glyph);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Tab 栏 ─────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** 渲染 Tab 栏(含进度点),返回行数组 */
|
|
74
|
+
export function renderTabBar(
|
|
75
|
+
core: Core,
|
|
76
|
+
width: number,
|
|
77
|
+
theme: { fg: (c: string, t: string) => string; bg: (c: string, t: string) => string },
|
|
78
|
+
): string[] {
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
const parts: string[] = ["◀"];
|
|
81
|
+
|
|
82
|
+
// 问题 Tab
|
|
83
|
+
for (let i = 0; i < core.questions.length; i++) {
|
|
84
|
+
const q = core.questions[i];
|
|
85
|
+
const isActive = i === core.currentIndex;
|
|
86
|
+
const color = core.getProgress(i);
|
|
87
|
+
const dot = progressDot(color, theme);
|
|
88
|
+
let text = ` ${dot} ${q.label} `;
|
|
89
|
+
|
|
90
|
+
if (isActive) {
|
|
91
|
+
if (color === "green") {
|
|
92
|
+
text = theme.fg("success", text);
|
|
93
|
+
} else if (color === "red") {
|
|
94
|
+
text = theme.fg("error", text);
|
|
95
|
+
} else {
|
|
96
|
+
text = theme.fg("text", text);
|
|
97
|
+
}
|
|
98
|
+
text = theme.bg("selectedBg", text);
|
|
99
|
+
} else if (color === "green") {
|
|
100
|
+
text = theme.fg("success", text);
|
|
101
|
+
} else if (color === "red") {
|
|
102
|
+
text = theme.fg("error", text);
|
|
103
|
+
}
|
|
104
|
+
parts.push(text);
|
|
105
|
+
parts.push("│");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 提交 Tab
|
|
109
|
+
const isSubmit = core.isSubmitTab();
|
|
110
|
+
const allDone = core.allAnswered();
|
|
111
|
+
let submitText = " ✓ Submit ";
|
|
112
|
+
if (isSubmit) {
|
|
113
|
+
submitText = theme.bg("selectedBg", allDone ? theme.fg("success", submitText) : theme.fg("text", submitText));
|
|
114
|
+
} else if (allDone) {
|
|
115
|
+
submitText = theme.fg("success", submitText);
|
|
116
|
+
} else {
|
|
117
|
+
submitText = theme.fg("dim", submitText);
|
|
118
|
+
}
|
|
119
|
+
parts.push(submitText);
|
|
120
|
+
parts.push("▶");
|
|
121
|
+
|
|
122
|
+
lines.push(truncateToWidth(" " + parts.join(""), width));
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── 问题标题 ───────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** 渲染问题标题行 */
|
|
129
|
+
export function renderPrompt(
|
|
130
|
+
q: FlatQuestion,
|
|
131
|
+
theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
|
|
132
|
+
): string {
|
|
133
|
+
return ` ${q.prompt}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── 提示栏 ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** 根据问题类型和状态生成底部提示文本 */
|
|
139
|
+
export function renderHelpBar(
|
|
140
|
+
q: FlatQuestion | undefined,
|
|
141
|
+
isMultiQuestion: boolean,
|
|
142
|
+
inputMode: boolean,
|
|
143
|
+
theme: { fg: (c: string, t: string) => string },
|
|
144
|
+
): string {
|
|
145
|
+
if (inputMode) {
|
|
146
|
+
return theme.fg("dim", " ← → 调整/移动 · Enter 保存 · Esc 退出编辑");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!q) {
|
|
150
|
+
// 提交页
|
|
151
|
+
return theme.fg("dim", " ← 回到问题 · Enter 提交 · Esc 取消");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
switch (q.type) {
|
|
155
|
+
case "select":
|
|
156
|
+
case "multiSelect": {
|
|
157
|
+
const base = `Space ${q.type === "multiSelect" ? "切换" : "选择"} · Tab 编辑自定义 · ↑↓ 移动 · Enter 提交`;
|
|
158
|
+
const nav = isMultiQuestion ? " · ← → 切换问题" : "";
|
|
159
|
+
const extra = " · Esc 取消";
|
|
160
|
+
return theme.fg("dim", base + nav + extra);
|
|
161
|
+
}
|
|
162
|
+
case "text":
|
|
163
|
+
return theme.fg("dim", " Tab 编辑 · Enter 提交 · Esc 取消" + (isMultiQuestion ? " · ← → 切换问题" : ""));
|
|
164
|
+
case "confirm":
|
|
165
|
+
return theme.fg("dim", " Tab 选择 · Enter 确认 · Esc 取消" + (isMultiQuestion ? " · ← → 切换问题" : ""));
|
|
166
|
+
case "rating":
|
|
167
|
+
return theme.fg("dim", " Tab 调整 · Enter 确认 · Esc 取消" + (isMultiQuestion ? " · ← → 切换问题" : ""));
|
|
168
|
+
default:
|
|
169
|
+
return theme.fg("dim", "");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── 提交汇总页 ─────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/** 渲染提交汇总页 */
|
|
176
|
+
export function renderSubmitPage(
|
|
177
|
+
core: Core,
|
|
178
|
+
theme: {
|
|
179
|
+
fg: (c: string, t: string) => string;
|
|
180
|
+
bold: (t: string) => string;
|
|
181
|
+
},
|
|
182
|
+
): string[] {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
|
|
185
|
+
lines.push(theme.fg("accent", theme.bold(" 确认你的选择:")));
|
|
186
|
+
lines.push("");
|
|
187
|
+
|
|
188
|
+
for (const q of core.questions) {
|
|
189
|
+
const answer = core.answers.get(q.id);
|
|
190
|
+
if (!answer) {
|
|
191
|
+
lines.push(` ⚠ ${theme.fg("error", q.label)}: ${theme.fg("dim", "(未回答)")}`);
|
|
192
|
+
} else if ('text' in answer) {
|
|
193
|
+
const display = answer.text.length > 0 ? answer.text : theme.fg("dim", "(未回答)");
|
|
194
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${display}`);
|
|
195
|
+
} else if ('confirmed' in answer) {
|
|
196
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${answer.label}`);
|
|
197
|
+
} else if ('value' in answer && typeof answer.value === 'number') {
|
|
198
|
+
const annot = answer.annotation ? ` (${answer.annotation})` : '';
|
|
199
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${answer.value}${annot}`);
|
|
200
|
+
} else if ('value' in answer && typeof answer.value === 'string') {
|
|
201
|
+
if (answer.value.length === 0) {
|
|
202
|
+
lines.push(` ⚠ ${theme.fg("error", q.label)}: ${theme.fg("dim", "(未回答)")}`);
|
|
203
|
+
} else if (answer.wasCustom) {
|
|
204
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${theme.fg("muted", "(自定义) ")}${answer.label}`);
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${answer.label}`);
|
|
207
|
+
}
|
|
208
|
+
} else if ('values' in answer) {
|
|
209
|
+
if (answer.values.length === 0) {
|
|
210
|
+
lines.push(` ⚠ ${theme.fg("error", q.label)}: ${theme.fg("dim", "(未回答)")}`);
|
|
211
|
+
} else {
|
|
212
|
+
const parts = answer.labels.join(', ');
|
|
213
|
+
if (answer.wasCustom) {
|
|
214
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${parts} ${theme.fg("muted", "· 自定义")}`);
|
|
215
|
+
} else {
|
|
216
|
+
lines.push(` ✓ ${theme.fg("success", q.label)}: ${parts}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
lines.push("");
|
|
223
|
+
|
|
224
|
+
const incomplete = core.incompleteQuestions();
|
|
225
|
+
if (incomplete.length > 0) {
|
|
226
|
+
const missing = core.questions
|
|
227
|
+
.filter((q) => incomplete.includes(q.id))
|
|
228
|
+
.map((q) => q.label)
|
|
229
|
+
.join(", ");
|
|
230
|
+
lines.push(` ⚠ ${theme.fg("error", "未完成:")} ${theme.fg("dim", missing)} → ${theme.fg("error", "无法提交")}`);
|
|
231
|
+
lines.push(` ${theme.fg("dim", "← 回到问题修改")}`);
|
|
232
|
+
} else {
|
|
233
|
+
lines.push(` ${theme.fg("success", "全部完成,按 Enter 提交")}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── 帮助函数:获取当前问题的选项列表 ──────────────────
|
|
240
|
+
|
|
241
|
+
/** 获取带"自定义"选项的完整选项列表 */
|
|
242
|
+
export function getOptionsWithCustom(q: FlatQuestion): Array<{
|
|
243
|
+
value: string;
|
|
244
|
+
label: string;
|
|
245
|
+
description?: string;
|
|
246
|
+
isCustom: boolean;
|
|
247
|
+
}> {
|
|
248
|
+
if (q.type !== "select" && q.type !== "multiSelect") return [];
|
|
249
|
+
const opts = q.options.map((o) => ({ ...o, isCustom: false }));
|
|
250
|
+
opts.push({ value: "__custom__", label: "自定义", description: undefined, isCustom: true });
|
|
251
|
+
return opts;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── renderCall / renderResult 辅助 ────────────────────
|
|
255
|
+
|
|
256
|
+
/** renderCall:显示问题数量和标签 */
|
|
257
|
+
export function renderCallText(
|
|
258
|
+
args: { questions?: Array<{ label?: string; id: string }> },
|
|
259
|
+
theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
|
|
260
|
+
): Text {
|
|
261
|
+
const qs = args.questions || [];
|
|
262
|
+
const count = qs.length;
|
|
263
|
+
const labels = qs.map((q) => q.label || q.id).join(", ");
|
|
264
|
+
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
|
265
|
+
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
|
266
|
+
if (labels) {
|
|
267
|
+
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
|
268
|
+
}
|
|
269
|
+
return new Text(text, 0, 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** renderResult:显示答案汇总 */
|
|
273
|
+
export function renderResultText(
|
|
274
|
+
result: { content: Array<{ type: string; text: string }>; details: unknown },
|
|
275
|
+
theme: { fg: (c: string, t: string) => string },
|
|
276
|
+
): Text {
|
|
277
|
+
const details = result.details as
|
|
278
|
+
| { cancelled?: boolean; message?: string; answers?: Record<string, Answer>; submittedAt?: string }
|
|
279
|
+
| undefined;
|
|
280
|
+
|
|
281
|
+
if (!details) {
|
|
282
|
+
const text = result.content[0];
|
|
283
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (details.cancelled) {
|
|
287
|
+
return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const answerMap = details.answers || {};
|
|
291
|
+
const entries = Object.entries(answerMap);
|
|
292
|
+
const lines = entries.flatMap(([id, a]) => {
|
|
293
|
+
const prefix = `${theme.fg("success", "✓ ")}${theme.fg("accent", id)}: `;
|
|
294
|
+
if ('text' in a) {
|
|
295
|
+
return [prefix + a.text];
|
|
296
|
+
}
|
|
297
|
+
if ('confirmed' in a) {
|
|
298
|
+
return [prefix + a.label];
|
|
299
|
+
}
|
|
300
|
+
if ('value' in a && typeof a.value === 'number') {
|
|
301
|
+
const annot = a.annotation ? ` (${a.annotation})` : '';
|
|
302
|
+
return [prefix + `${a.value}${annot}`];
|
|
303
|
+
}
|
|
304
|
+
if ('value' in a && typeof a.value === 'string') {
|
|
305
|
+
if (a.value.length === 0) return [prefix + theme.fg("dim", "(none)")];
|
|
306
|
+
if (a.wasCustom) return [prefix + theme.fg("muted", "(wrote) ") + a.label];
|
|
307
|
+
return [prefix + a.label];
|
|
308
|
+
}
|
|
309
|
+
if ('values' in a) {
|
|
310
|
+
if (a.values.length === 0) return [prefix + theme.fg("dim", "(none)")];
|
|
311
|
+
const parts = a.labels.join(', ');
|
|
312
|
+
if (a.wasCustom) return [prefix + parts + ' ' + theme.fg("muted", "· (wrote)")];
|
|
313
|
+
return [prefix + parts];
|
|
314
|
+
}
|
|
315
|
+
return [prefix + theme.fg("dim", "(none)")];
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
319
|
+
}
|