@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.
@@ -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
- if (parts.length === 0) return null;
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
- const isSubmit = focus === "submit";
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. Plain text only (no newlines or control characters).",
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;